diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7ac5c7b8..e27cc4c8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,38 +1,18 @@ name: CI on: - pull_request: push: - tags: - - 'v*' - -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + branches: [main] + pull_request: + branches: [main] jobs: - test: + build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: 24 + node-version: 22 - run: npm ci - run: npx tsc --noEmit - - run: npm test - - publish: - needs: test - if: startsWith(github.ref, 'refs/tags/v') - runs-on: ubuntu-latest - permissions: - contents: read - id-token: write - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 24 - registry-url: https://registry.npmjs.org - - run: npm ci - - run: npm publish --provenance --access public diff --git a/.github/workflows/gitleaks.yml b/.github/workflows/gitleaks.yml new file mode 100644 index 00000000..2cb96869 --- /dev/null +++ b/.github/workflows/gitleaks.yml @@ -0,0 +1,16 @@ +name: Gitleaks + +on: + pull_request: + branches: [main] + +jobs: + scan: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: gitleaks/gitleaks-action@v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 00000000..d21484dd --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,66 @@ +name: Publish + +on: + push: + tags: ['v*'] + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + registry-url: https://registry.npmjs.org + - run: npm ci + - run: npx tsc --noEmit + - run: npm run build + + # Publish main package as @songsid/agend + - name: Set version from tag + run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV + - name: Determine npm tag + run: | + if [[ "$VERSION" == *-beta* ]]; then + echo "NPM_TAG=beta" >> $GITHUB_ENV + else + echo "NPM_TAG=latest" >> $GITHUB_ENV + fi + - name: Swap package name and version + run: | + sed -i 's/"name": "@suzuke\/agend"/"name": "@songsid\/agend"/' package.json + sed -i "s/\"version\": \".*\"/\"version\": \"$VERSION\"/" package.json + - run: npm publish --access public --tag ${{ env.NPM_TAG }} + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + # Publish Discord plugin as @songsid/agend-plugin-discord + - name: Build Discord plugin + working-directory: plugins/agend-plugin-discord + run: | + npm install + npm run build + - name: Swap Discord plugin names + working-directory: plugins/agend-plugin-discord + run: | + sed -i 's/"name": "@suzuke\/agend-plugin-discord"/"name": "@songsid\/agend-plugin-discord"/' package.json + sed -i "s/\"version\": \".*\"/\"version\": \"$VERSION\"/" package.json + sed -i 's/@suzuke\/agend/@songsid\/agend/g' package.json + sed -i 's/">=1.13.0"/">='$VERSION'"/' package.json + find dist -name '*.js' -exec sed -i 's/@suzuke\/agend/@songsid\/agend/g' {} + + find dist -name '*.d.ts' -exec sed -i 's/@suzuke\/agend/@songsid\/agend/g' {} + + - name: Publish Discord plugin + working-directory: plugins/agend-plugin-discord + run: npm publish --access public --tag ${{ env.NPM_TAG }} + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + # Create GitHub Release with auto-generated notes from merged PRs + - name: Create GitHub Release + if: "!contains(env.VERSION, 'beta')" + run: gh release create "v$VERSION" --generate-notes --title "v$VERSION" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 3b506829..375593c7 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,14 @@ website/.astro/ # E2E test results (generated by vitest in VM, rsynced back) e2e/results/ .kiro/settings/mcp.json +plugins/agend-plugin-discord/package-lock.json + +# NPM auth (generated during publish, auto-cleaned) +.npmrc + +# Session files (Claude Code / OpenCode) +*.session.json +20[0-9][0-9][01][0-9][0-3][0-9].json +opencode.json +.kiro/settings/lsp.json +.kiro/steering/ diff --git a/.kiro/settings/lsp.json b/.kiro/settings/lsp.json new file mode 100644 index 00000000..deacaff2 --- /dev/null +++ b/.kiro/settings/lsp.json @@ -0,0 +1,198 @@ +{ + "languages": { + "typescript": { + "name": "typescript-language-server", + "command": "typescript-language-server", + "args": [ + "--stdio" + ], + "file_extensions": [ + "ts", + "js", + "tsx", + "jsx" + ], + "project_patterns": [ + "package.json", + "tsconfig.json" + ], + "exclude_patterns": [ + "**/node_modules/**", + "**/dist/**" + ], + "multi_workspace": false, + "initialization_options": { + "preferences": { + "disableSuggestions": false + } + }, + "request_timeout_secs": 60 + }, + "ruby": { + "name": "solargraph", + "command": "solargraph", + "args": [ + "stdio" + ], + "file_extensions": [ + "rb" + ], + "project_patterns": [ + "Gemfile", + "Rakefile" + ], + "exclude_patterns": [ + "**/vendor/**", + "**/tmp/**" + ], + "multi_workspace": false, + "initialization_options": {}, + "request_timeout_secs": 60 + }, + "rust": { + "name": "rust-analyzer", + "command": "rust-analyzer", + "args": [], + "file_extensions": [ + "rs" + ], + "project_patterns": [ + "Cargo.toml" + ], + "exclude_patterns": [ + "**/target/**" + ], + "multi_workspace": false, + "initialization_options": { + "cargo": { + "buildScripts": { + "enable": true + } + }, + "diagnostics": { + "enable": true, + "enableExperimental": true + }, + "workspace": { + "symbol": { + "search": { + "scope": "workspace" + } + } + } + }, + "request_timeout_secs": 60 + }, + "java": { + "name": "jdtls", + "command": "jdtls", + "args": [], + "file_extensions": [ + "java" + ], + "project_patterns": [ + "pom.xml", + "build.gradle", + "build.gradle.kts", + ".project" + ], + "exclude_patterns": [ + "**/target/**", + "**/build/**", + "**/.gradle/**" + ], + "multi_workspace": false, + "initialization_options": { + "settings": { + "java": { + "compile": { + "nullAnalysis": { + "mode": "automatic" + } + }, + "configuration": { + "annotationProcessing": { + "enabled": true + } + } + } + } + }, + "request_timeout_secs": 60 + }, + "python": { + "name": "pyright", + "command": "pyright-langserver", + "args": [ + "--stdio" + ], + "file_extensions": [ + "py" + ], + "project_patterns": [ + "pyproject.toml", + "setup.py", + "requirements.txt", + "pyrightconfig.json" + ], + "exclude_patterns": [ + "**/__pycache__/**", + "**/venv/**", + "**/.venv/**", + "**/.pytest_cache/**" + ], + "multi_workspace": false, + "initialization_options": {}, + "request_timeout_secs": 60 + }, + "go": { + "name": "gopls", + "command": "gopls", + "args": [], + "file_extensions": [ + "go" + ], + "project_patterns": [ + "go.mod", + "go.sum" + ], + "exclude_patterns": [ + "**/vendor/**" + ], + "multi_workspace": false, + "initialization_options": { + "usePlaceholders": true, + "completeUnimported": true + }, + "request_timeout_secs": 60 + }, + "cpp": { + "name": "clangd", + "command": "clangd", + "args": [ + "--background-index" + ], + "file_extensions": [ + "cpp", + "cc", + "cxx", + "c", + "h", + "hpp", + "hxx" + ], + "project_patterns": [ + "CMakeLists.txt", + "compile_commands.json", + "Makefile" + ], + "exclude_patterns": [ + "**/build/**", + "**/cmake-build-**/**" + ], + "multi_workspace": false, + "initialization_options": {}, + "request_timeout_secs": 60 + } + } +} \ No newline at end of file diff --git a/.kiro/settings/mcp.json b/.kiro/settings/mcp.json deleted file mode 100644 index 86b7dfd2..00000000 --- a/.kiro/settings/mcp.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "mcpServers": { - "agend-agend-ts-dev-t15813": { - "command": "/Users/suzuke/.agend/instances/agend-ts-dev-t15813/mcp-wrapper-agend.sh", - "args": [] - } - } -} \ No newline at end of file diff --git a/.kiro/steering/agend-ux-dev-t14419.md b/.kiro/steering/agend-ux-dev-t14419.md deleted file mode 100644 index b80bf4e3..00000000 --- a/.kiro/steering/agend-ux-dev-t14419.md +++ /dev/null @@ -1,93 +0,0 @@ -# AgEnD Fleet Context -You are **ux-dev-t14419**, an instance in an AgEnD fleet. -Your working directory is `/Users/suzuke/Documents/Hack/agend-fix-user-pain-points`. - -You don't have a display name yet. Use set_display_name to choose one that reflects your personality. - -## Role -Developer — 解決用戶回饋的 fleet config 和 instructions 問題 - -## Message Format -- `[user:name]` — from a Telegram/Discord user → reply with the `reply` tool. -- `[from:instance-name]` — from another fleet instance → reply with `send_to_instance`, NOT the reply tool. - -**Always use the `reply` tool for ALL responses to users.** Do not respond directly in the terminal. - -## Tool Usage -- reply: respond to users. react: emoji reactions. edit_message: update a sent message. download_attachment: fetch files. -- If the inbound message has image_path, Read that file — it is a photo. -- If the inbound message has attachment_file_id, call download_attachment then Read the returned path. -- If the inbound message has reply_to_text, the user is quoting a previous message. -- Use list_instances to discover fleet members. Use describe_instance for details. -- High-level collaboration: request_information (ask), delegate_task (assign), report_result (return results with correlation_id). - -## Collaboration Rules -1. Use fleet tools for cross-instance communication. Never assume direct file access to another instance's repo. -2. Cross-instance messages appear as `[from:instance-name]`. Reply via send_to_instance or report_result, NOT reply. -3. Use list_instances to discover available instances before sending messages. -4. You only have direct access to files under your own working directory. - -## Development Workflow - -# Fleet Collaboration - -## Communication Rules - -- **Direct communication**: talk to other instances directly via `send_to_instance`. Don't relay through a coordinator. -- **Structured handoffs**: use `delegate_task` (with clear scope) and `report_result` (with correlation_id). -- **Ask, don't assume**: use `request_information` when you need context from another instance. -- **No ack spam**: don't send "got it" / "working on it" unless asked for status. Report when done. - -## Shared Decisions - -- Run `list_decisions` after context rotation to reload fleet-wide decisions. -- Use `post_decision` to share architectural choices that affect other instances. - -## Progress Tracking - -Use the **Task Board** (`task` tool) for multi-step work: -- Break work into discrete tasks with clear deliverables -- Update status as you progress (pending → in_progress → done) -- Other instances can check your task board for status instead of asking - -## Context Protection - -- **Large searches**: use subagents (Agent tool) instead of reading many files directly -- **Big codebases**: glob/grep for specific targets, don't read entire directories -- **Long conversations**: summarize decisions into Shared Decisions before context fills up -- Watch your context usage; when it's high, wrap up current work and let context rotation handle the rest - - -## Active Decisions - -- **Release 流程:需要打 git tag 觸發 CI**: npm publish 由 GitHub Actions CI 處理,觸發條件是 git tag push(不是 commit push)。Release 流程:1 -- **Code Review: 關鍵路徑 failure mode 分析**: Code review 時必須系統性分析關鍵路徑的 failure mode: -- **Review policy: infra bug fixes must scan all affected code paths**: When fixing infrastructure-level bugs (tmux, IPC, lifecycle), code review must not be scoped to only the modified lines -- **Cross-instance notification 改善方案(待實作)**: 問題:send_to_instance 目前把完整訊息貼到 sender 和 target 兩個 Telegram topic,general topic 被大量訊息淹沒。 -- **Every instance should have a display name**: New instances should use set_display_name to choose a name on first startup -- **Task cancel + agent interrupt design**: Use Escape for Claude Code/Gemini (safe), Ctrl+C for Codex/OpenCode -- **GitHub automation policy**: Agents can create PRs, comment on issues, and approve PRs -- **tmux delimiter: keep ||| but test 0x1f under launchd**: Current: tmux listWindows uses ||| as format delimiter (v1 -- **Push requires user approval**: All git push to origin must wait for user (chiachenghuang) explicit approval before executing -- **Semantic versioning discipline**: Minor (1 - -你是 UX-focused developer。任務:針對真實用戶回饋的問題設計最優雅的解決方案。 - -用戶回饋的問題: - -1. /resume vs /new — instructions 讀取不一致 - - Codex /resume 不一定重讀 AGENTS.md,/new 才 100% 重讀 - - 目前 fleet start/restart 用 --resume,有機率讀到舊 instructions - - 問題:instructions 更新後,CLI 不一定看到新版 - -2. 單一 instance restart 不重讀 fleet.yaml - - agend fleet restart 不重載 fleet.yaml - - 所以 writeConfig 產生的 AGENTS.md 不會更新 - - 用戶改了 fleet.yaml 後要全部重啟才生效 - -3. Web UI 建 instance 欄位不足 - - 缺 tags、systemPrompt、worktree_source - - 建完後要手動改 fleet.yaml 再全部重啟 - -先讀 codebase 理解現有 restart 和 config reload 流程,然後提出設計方案。 -Follow Development Workflow policy。 \ No newline at end of file diff --git a/.nojekyll b/.nojekyll new file mode 100644 index 00000000..e69de29b diff --git a/README.md b/README.md index bfa17ec7..ed56a796 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Run a fleet of AI coding agents from your phone.

- npm + npm License: MIT Node.js >= 20

@@ -35,7 +35,7 @@ AgEnD (**Agent Engineering Daemon**) turns your Telegram or Discord into a comma 🚀 **Fleet Management** — One bot, N projects. Each Telegram Forum Topic is an isolated agent session. -🔄 **Multi-Backend** — Claude Code, Gemini CLI, Codex, OpenCode, Kiro CLI. Switch or mix freely. +🔄 **Multi-Backend** — Claude Code, Gemini CLI, Codex, OpenCode, Kiro CLI, Antigravity CLI. Switch or mix freely. 🤝 **Agent Collaboration** — Agents discover, wake, and message each other via MCP tools. A General Topic routes tasks to the right agent using natural language. @@ -64,24 +64,24 @@ AgEnD (**Agent Engineering Daemon**) turns your Telegram or Discord into a comma One-liner (macOS / Linux — installs Node.js via nvm + tmux + agend, then runs quickstart): ```bash -curl -fsSL https://suzuke.github.io/AgEnD/install.sh | bash +curl -fsSL https://songsid.github.io/AgEnD/install.sh | bash ``` Or install manually: ```bash # Option A: One-line install (Linux / macOS / WSL) -curl -fsSL https://suzuke.github.io/AgEnD/install.sh | bash +curl -fsSL https://songsid.github.io/AgEnD/install.sh | bash # Option B: Manual install -npm install -g @suzuke/agend # 1. Install +npm install -g @songsid/agend # 1. Install agend quickstart # 2. Setup — bot token, backend, done agend fleet start # 3. Launch your fleet 🎉 ``` Open Telegram, send a message to your bot, and start coding from your phone. -> **Discord?** `agend quickstart` supports Discord too — install the plugin first: `npm install -g @suzuke/agend-plugin-discord`. See [Discord setup guide](docs/features.md#discord-adapter-mvp). +> **Discord?** `agend quickstart` supports Discord too — install the plugin first: `npm install -g @songsid/agend-plugin-discord`. See [Discord setup guide](docs/features.md#discord-adapter-mvp). ## How It Works @@ -112,9 +112,10 @@ graph LR |---------|---------|------| | Claude Code | `curl -fsSL https://claude.ai/install.sh \| bash` | `claude` (OAuth) or `ANTHROPIC_API_KEY` | | OpenAI Codex | `npm i -g @openai/codex` | `codex` (ChatGPT login) or `OPENAI_API_KEY` | -| Gemini CLI | `npm i -g @google/gemini-cli` | `gemini` (Google OAuth) | +| Gemini CLI | `npm i -g @google/gemini-cli` | `gemini` (Google OAuth) ⚠️ Deprecated 2026-06-18 | | OpenCode | `curl -fsSL https://opencode.ai/install \| bash` | `opencode` (configure provider) | -| Kiro CLI | `brew install --cask kiro-cli` | `kiro-cli login` (AWS Builder ID) | +| Kiro CLI | `curl -fsSL https://cli.kiro.dev/install | bash` | `kiro-cli login` (AWS Builder ID) | +| Antigravity CLI | `curl -fsSL https://antigravity.google/cli/install.sh \| bash` | `agy` (Google Sign-In) | ## Requirements @@ -131,7 +132,7 @@ graph LR > [interop] > appendWindowsPath=false > ``` -> Then restart WSL (`wsl --shutdown`). Install with: `curl -fsSL https://suzuke.github.io/AgEnD/install.sh | bash` +> Then restart WSL (`wsl --shutdown`). Install with: `curl -fsSL https://songsid.github.io/AgEnD/install.sh | bash` ## Documentation @@ -140,6 +141,65 @@ graph LR - [Configuration](docs/configuration.md) — fleet.yaml complete reference - [Security](SECURITY.md) — trust model and hardening +## Discord ClassicBot + +ClassicBot lets users start AI agents in any Discord text channel using slash commands — no forum topics required. + +### Setup + +```bash +# 1. Install Discord plugin +npm install -g @songsid/agend-plugin-discord + +# 2. Run quickstart (select Discord) +agend quickstart + +# 3. Start the fleet +agend fleet start +``` + +The quickstart will set up both `fleet.yaml` and `classicBot.yaml`. Run `agend quickstart` again to add users or guilds to existing config. + +### Usage + +| Command | Description | +|---------|-------------| +| `/start` | Start an agent in the current channel | +| `/chat ` | Send a message to the agent | +| `/stop` | Stop the agent in the current channel | + +### Server Whitelist + +Control which Discord servers can use ClassicBot via `~/.agend/classicBot.yaml`: + +```yaml +defaults: + backend: claude-code + allowed_guilds: # Only these servers can /start + - "1496855124440780912" + - "9876543210123456789" +``` + +- **Empty or omitted** `allowed_guilds` → all servers allowed (default) +- **Primary guild** (fleet.yaml `group_id`) → full access (topic mode + ClassicBot) +- **Whitelisted guilds** → ClassicBot only (`/start`, `/chat`, `/stop`) +- **Hot reload** — changes detected every 30 seconds, no restart needed + +### Per-Channel Backend + +Override the backend for specific channels: + +```yaml +defaults: + backend: claude-code +channels: + "1234567890": # Discord channel ID + name: dev-help + backend: gemini-cli # Override for this channel +``` + +Backend fallback: channel → `defaults.backend` → `fleet.yaml` defaults → `claude-code` + ## Known Limitations - macOS (launchd) and Linux (systemd) supported; Windows is not diff --git a/README.zh-TW.md b/README.zh-TW.md index 8fdabd3c..1229797f 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -4,7 +4,7 @@ 用手機管理一整個 AI coding agent 團隊。

- npm + npm License: MIT Node.js >= 20

@@ -35,7 +35,7 @@ AgEnD(**Agent Engineering Daemon**)把你的 Telegram 或 Discord 變成 AI 🚀 **Fleet 管理** — 一個 bot、N 個專案。每個 Telegram Forum Topic 就是獨立的 agent session。 -🔄 **多後端支援** — Claude Code、Gemini CLI、Codex、OpenCode、Kiro CLI,自由切換或混用。 +🔄 **多後端支援** — Claude Code、Gemini CLI、Codex、OpenCode、Kiro CLI、Antigravity CLI,自由切換或混用。 🤝 **Agent 協作** — Agent 之間透過 MCP tools 互相發現、喚醒、傳訊。General Topic 用自然語言把任務路由到對的 agent。 @@ -60,13 +60,13 @@ AgEnD(**Agent Engineering Daemon**)把你的 Telegram 或 Discord 變成 AI 一行安裝(macOS / Linux — 自動裝 Node.js(經 nvm)+ tmux + agend,完成後跑 quickstart): ```bash -curl -fsSL https://suzuke.github.io/AgEnD/install.sh | bash +curl -fsSL https://songsid.github.io/AgEnD/install.sh | bash ``` 或手動安裝: ```bash -npm install -g @suzuke/agend # 1. 安裝 +npm install -g @songsid/agend # 1. 安裝 agend quickstart # 2. 設定 — bot token、backend,搞定 agend fleet start # 3. 啟動 fleet 🎉 ``` @@ -104,7 +104,8 @@ graph LR | OpenAI Codex | `npm i -g @openai/codex` | `codex`(ChatGPT 登入)或 `OPENAI_API_KEY` | | Gemini CLI | `npm i -g @google/gemini-cli` | `gemini`(Google OAuth) | | OpenCode | `curl -fsSL https://opencode.ai/install \| bash` | `opencode`(設定 provider) | -| Kiro CLI | `brew install --cask kiro-cli` | `kiro-cli login`(AWS Builder ID) | +| Kiro CLI | `curl -fsSL https://cli.kiro.dev/install | bash` | `kiro-cli login`(AWS Builder ID) | +| Antigravity CLI | `curl -fsSL https://antigravity.google/cli/install.sh \| bash` | `agy`(Google Sign-In) | ## 系統需求 diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 24f7974b..a202efa7 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -4,7 +4,173 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). -## [Unreleased] +## [2.0.1] - 2026-06-15 + +### Added +- **Telegram Rich Messages** — grammy 1.44.0, auto-detects markdown tables/code blocks/headings → sendRichMessage with fallback. +- **`/update` + `/doctor` commands** — available in both TG and Discord (admin only). /doctor runs backend diagnostics. +- **systemd watchdog** — Type=notify, WatchdogSec=60, sd_notify via systemd-notify command. +- **Non-blocking startup** — generals start first → READY=1 → remaining instances in background. +- **Daily update check** — fleet daemon checks npm for new versions every 24h, notifies General. +- **Admin reject notification** — non-admin /start or /stop triggers notification to General with user info. +- **Workspace path guard** — create_instance rejects dangerous paths (`.`, `~`, `/`). +- **npm link auto-detect** — `agend update` detects and removes stale npm link before install. +- **install.sh link removal** — readlink fallback to detect npm-linked old versions. +- **Slash command prefixes** — [Fleet] / [ClassicBot] in TG+DC command descriptions. +- **kiro-cli error detection** — "having trouble responding" triggers rate_limit notification. +- **`/status` + `/sysinfo` rich tables** — markdown table output for TG Rich Message rendering. + +### Fixed +- **systemd startup kill** — NotifyAccess=all + TimeoutStartSec=0 for 50+ instance fleets. +- **Classic group unbound message** — no longer shows "not bound to an instance" in classic groups. +- **`agend update` message** — says `agend start` instead of `agend fleet start`. +- **Collab empty log** — skip empty bot messages in collab chat log. +- **`/doctor` command path** — uses `agend backend doctor` with fleet default backend. + +### Changed +- **Versioning** — jumped from v0.0.23 to v2.0.0. New versioning starts from v2.x. +- **PR flow** — all changes go through feature branch → PR → merge. Branch protection on main. +- **CI auto GitHub Release** — stable tags auto-create GitHub Release with generated notes. + +## [2.0.0] - 2026-06-15 + +Same content as v0.0.23. Version bump to establish new major version baseline. + +## [0.0.23] - 2026-06-12 + +### Added +- **Permissions matrix** — `docs/permissions.md` documents all commands × platforms × access levels. + +### Fixed +- **TG classic `botUsername` never set on primary adapter** — `isBotMentioned` was always false. Now correctly sets `world.botUsername` in the `started` event handler and registers listeners before `adapter.start()`. +- **TG `/start@other_bot` triggers all bots** — commands with `@suffix` targeting another bot are now ignored entirely. +- **TG `/start` `/stop` `/raw` admin lock** — group-mode `/start` and `/stop` now require `admin_users`. `@bot /raw` also requires admin. +- **`allowed_guilds: {}` (non-array) breaks access** — non-array values are now treated as "allow all" instead of rejecting everything. + +## [0.0.22] - 2026-06-12 + +### Fixed +- **Classic instance proactive reply** — daemon no longer blocks `reply` tool when no prior inbound message exists. Fleet-manager's classicBot channelId fallback now correctly routes outbound messages. + +## [0.0.21] - 2026-06-12 + +### Added +- **Mention rules in fleet instructions** — all instances now know how to `<@USER_ID>` mention Discord users/bots and `@username` for Telegram. Extracted from `id:` field in inbound messages. + +## [0.0.20] - 2026-06-12 + +### Added +- **User ID in inbound messages** — format now includes `id:USER_ID` for mention support. Agents can `<@ID>` to mention Discord users or use Telegram mention syntax. + +### Fixed +- **Classic instance reply fallback** — classic channel agents can now reply even after fleet restart. Falls back to `classicBot.yaml` channelId when `topic_id` is unavailable. + +## [0.0.19] - 2026-06-11 + +### Fixed +- **agy model discovery skill** — clarify that effort suffix (Medium/High/Low/Thinking) is not part of the model name. + +## [0.0.18] - 2026-06-11 + +### Added +- **Model compatibility check** — `defaults.model` only applies to backends that recognize the model name pattern. Incompatible models are silently skipped (e.g. `claude-opus-4.6` won't be passed to Codex). + +## [0.0.17] - 2026-06-11 + +### Added +- **Antigravity CLI backend** — full support for Google's `agy` CLI. Uses CLI mode by default (no MCP). Non-hidden workspace at `~/agend-workspaces/`, instructions in `.agents/agents.md`, trust prompt auto-dismiss. +- **IPC + adapter auto-reconnect** — IPC disconnect retries with exponential backoff then every 60s indefinitely. Adapter fatal errors (Telegram polling init, Discord gateway) also auto-restart with same strategy. Dead tmux panes are auto-respawned. +- **Beta update channel** — `agend update --beta` installs from `@beta` npm dist-tag. CI publishes with `--tag beta` when git tag contains `-beta`. +- **PSS memory reporting** — `agend ls` uses Proportional Set Size from `/proc//smaps_rollup` instead of RSS to avoid shared page double-counting. +- **Parallel instance stop** — shutdown uses concurrency 5 for faster fleet stop. Systemd timeout extended accordingly. +- **Configurable context_lines** — per-channel chat log injection depth in classicBot.yaml. Set 0 to disable. +- **Model support in classicBot.yaml** — per-channel model override. +- **Access mode "open"** — allows all users without an allowlist. +- **Fleet memory total** — `agend ls` footer shows instance count and total memory. +- **`agend update` command** — full lifecycle: sudo/nvm detection, npm install, service restart, health check. +- **GitHub Actions CI/CD** — ci, publish, and gitleaks workflows. +- **Workspace git init** — auto-created workspaces get `git init` for CLI backend project root detection. +- **`/agent` endpoint auth bypass** — POST /agent uses instance-level token, skips web UI token check. +- **agy `--model` flag** — pass model selection to antigravity CLI. +- **General-knowledge refactor** — split into `steering/` (always loaded core rules) + `skills/` (on-demand with YAML frontmatter). Reduces General's default context usage. +- **Dynamic model discovery** — skills teach General to run CLI commands (`agy models`, `/model`) instead of hard-coded model lists. + +### Fixed +- Install script: auto-detect sudo, nvm-aware PATH, build-essential for native modules, /usr/local/bin symlinks only as root. +- Discord: stickers no longer treated as photo attachments; collab mode chat log includes attachment filenames. +- Daemon: unified log rotation; stale context rotation references removed from prompts. +- Update: kill old fleet process before restart; run daemon-reload before systemctl restart. +- Discord react: use `threadId` instead of `chatId` (guild ID) for 👀, ⏳, ✅ reactions. +- `agend update` restart: add `reset-failed` before `systemctl start` to handle post-kill failed state. +- Cross-instance silence: allow agents to stay silent when they have nothing to add. +- Default `context_lines` reduced from 10 to 5. + +### Performance +- Parallel instance stop with concurrency 5. +- Staggered restart notifications. +- Discord `react()` uses single REST PUT instead of 3 sequential API calls (fetchChannel → fetchMessage → react). ~1s → ~300ms. +- 👀 auto-react moved before `setTopicIcon`/`archive`/`processAttachments` for instant feedback. + +### Deprecated +- **gemini-cli** — sunset 2026-06-18. Warning shown on fleet start. + +## [1.24.0] - 2026-04-21 + +### Added +- **Discord quickstart UX** — plugin check, channel selection, options output. + +### Fixed +- Health check stops when instance directory is removed externally. +- NaN crash on non-numeric input; plugin check uses `npm list -g`. + +## [1.23.0] - 2026-04-20 + +Phase 1–4 of the security/reliability fix plan (`docs/fix-plan.md`) closes here. 36 individual fixes/refactors across 7 PRs (#33, #38, #39, #40, #41, #42, #43, #44). + +### Security +- **Phase 1 boundaries** (PR #33) — per-instance `/agent` token, zod validation on all `/ui/*` mutations, template var sanitization, tar entry validation, symlink resolution for `project_roots`, branch/logPath argument injection hardening, `web.token` 0o600. +- **Telegram apiRoot allowlist** (P3.3, `9a7b16b`) — prevents bot-token exfil via attacker-controlled `apiRoot`. +- **Webhook HMAC-SHA256 signing** (P3.1, `e65b97c`) — outbound webhooks now signed; receivers can verify origin. +- **STT requires explicit opt-in** (P3.4, `1fc513e`) — voice transcription no longer activates from env var alone; needs `stt.enabled: true` in `fleet.yaml`. +- **`/update` hardened** (P3.6, `740c202`, `d38a583`) — empty `allowed_users` rejects `/update` entirely; two-step token confirm (8 hex, 60s TTL); version pin during install; auto-rollback on health check fail; supersede notifications. +- **`access-path` rejects path traversal in instance names** (P4.3, `d5d41b7`) — whitelist `^[A-Za-z0-9._-]+$`, rejects `..` / `/` / `\` / NUL. +- **`.env` file mode 0o600** (P4.4, `49a4328`) — wizard writes credential files with restrictive permissions + chmod fallback. +- **CORS tightened, Bearer auth supported** (P3.5, `b180232`) — wildcard CORS removed; web API accepts `Authorization: Bearer ` header. +- **`paths.ts` md5 → sha256** (P4.5, `1f91c3c`) — eliminates FIPS / scanner alerts. Custom `AGEND_HOME` users will see tmux session/socket suffix change once on upgrade. + +### Fixed +- **Telegram 409 polling cap** (P3.2, `c67f776`) — caps retries to 30 to prevent infinite poll loops. +- **Topic archiver persistence** (P2.6, `f134a66`, `42d5d1f`) — archived topic state now persists across restarts via atomic write of `/archived-topics.json`. +- **IPC single-line buffer cap 10MB → 1MB** (P3.7, `d446384`) — overflow rejected with structured error instead of OOM. +- **Tmux pane cache invalidation on control-mode reconnect** (P2.1, `e967bbb`). +- **TranscriptMonitor reentry guard** (P2.4, `65be144`) — prevents overlapping `pollIncrement` runs. +- **Scheduler catch-up for missed runs within 24h** (P2.3, `01e1e32`, `24d6f8a`). +- **Cost-guard daily-cap reset on session rotation** (P2.2, `875a0b2`) — `warnEmitted`/`limitEmitted` flags now reset properly so post-rotation sessions don't silently exceed daily cap. +- **SSE dead client eviction + socket error handling** (P2.5, `ae2a810`) — `broadcastSseEvent` no longer breaks the loop on a dead client write; `req.on("error")` now cleans up client set on ECONNRESET. +- **Drop redundant sleep+reconnect after instance start** (P2.7, `872547b`) — `startInstance` await chain already guarantees IPC ready; secondary `connectIpcToInstance` was pure dead code. +- **Cost-guard DST handling** (P2.8, `3c9ff9f`) — `msUntilMidnight` now uses `Intl.DateTimeFormat` + binary search instead of `setHours(24,0,0,0)`, so DST spring/fall transitions don't shift the daily reset by ±1h. +- **MessageQueue flood-control backoff reset** (P3.8, `3474c04`) — backoff now actually resets after status_update drop instead of staying at ~30s. + +### Changed +- **`fleet-manager.ts` decomposed** (P4.1, PR #43) — 2842 → 1658 lines (-1184). Four new modules: + - `fleet-dashboard-html.ts` (442 lines) — dashboard HTML constant + - `fleet-instructions.ts` (168 lines) — `GENERAL_INSTRUCTIONS` + `ensureGeneralInstructions` + - `fleet-rpc-handlers.ts` (387 lines) — IPC + HTTP CRUD dispatch + - `fleet-health-server.ts` (326 lines) — `startHealthServer` + `getUiStatus` + `extractWebToken` + + All modules use a Context-injection pattern: each declares a narrow `XxxContext` interface, FleetManager `implements` it, and exported functions take `this` as their first arg. +- **`daemon.handleToolCall` factored** (P4.2, `e6a9596`) — extracted `dispatchFleetRpc(fleetReqId, broadcast, timeoutMs, timeoutMessage, respond)` helper. `handleToolCall` 182 → ~120 lines, daemon.ts -51 lines net. +- **`validateTimezone` unified** (P4.4, `49a4328`) — `scheduler/scheduler.ts` no longer duplicates the validator; imports the canonical version from `config.ts`. + +### Docs +- **`docs/fix-plan.md` Phase 1–4 closed** — all P-items either ✅ or moved to **Deferred / Future Work** (logger rotation, cost-guard tiebreaker — both feature-class, not fix-class). +- **`docs/p4.1-split-plan.md` archived** — record of the four-module decomposition strategy. +- **`docs/issue-evaluations.md` added** — analysis of open issues #24 (usage-limit notify) and #8 (default topic preset) with effort/tradeoff breakdowns for future planning. + +## [1.22.1] - 2026-04-19 + +### Fixed +- **Discord attachment download** — `downloadAttachment()` now actually works. Attachments are fetched from the Discord CDN and written to `inboxDir` during `messageCreate` (before the CDN URL expires), and `downloadAttachment()` returns the local path. Also: image attachments are classified as `photo` (enables auto-download on the agent side), filenames are prefixed with the Discord attachment ID to prevent collisions, downloads run in parallel across a message's attachments, failures are logged instead of silently swallowed, and `stop()` cleans up any undrained files. Closes #27. ## [1.22.0] - 2026-04-18 diff --git a/docs/CHANGELOG.zh-TW.md b/docs/CHANGELOG.zh-TW.md index 2113e83f..bfa7e0e6 100644 --- a/docs/CHANGELOG.zh-TW.md +++ b/docs/CHANGELOG.zh-TW.md @@ -6,6 +6,89 @@ ## [未發佈] (Unreleased) +### 新增 (Added) +- **Antigravity CLI 後端** — 完整支援 Google `agy` CLI。預設使用 CLI 模式(無 MCP)。非隱藏路徑 workspace `~/agend-workspaces/`,instructions 寫入 `.agents/agents.md`,trust 提示自動 dismiss。 +- **IPC + adapter 自動重連** — IPC 斷線後指數退避重試,之後每 60 秒無限重試。Adapter 致命錯誤(Telegram polling 初始化、Discord gateway)同策略自動重啟。死亡 tmux pane 自動 respawn。 +- **Beta 更新頻道** — `agend update --beta` 從 `@beta` npm dist-tag 安裝。CI 偵測 tag 含 `-beta` 時以 `--tag beta` 發布。 +- **PSS 記憶體報告** — `agend ls` 使用 PSS 取代 RSS,避免共用頁面重複計算。 +- **平行 instance 停止** — 併發數 5 加速關閉,systemd timeout 相應延長。 +- **可配置 context_lines** — classicBot.yaml 個別 channel 聊天記錄注入深度,設 0 停用。 +- **classicBot.yaml model 支援** — 個別 channel 模型覆蓋。 +- **Access mode "open"** — 允許所有使用者,無需白名單。 +- **Fleet 記憶體總計** — `agend ls` footer 顯示 instance 數量與總記憶體。 +- **`agend update` 指令** — 完整生命週期:sudo/nvm 偵測、npm install、service 重啟、健康檢查。 +- **GitHub Actions CI/CD** — ci、publish、gitleaks workflows。 +- **Workspace git init** — 自動建立的 workspace 執行 `git init`,確保 CLI backend 正確辨識 project root。 +- **`/agent` endpoint auth bypass** — POST /agent 使用 instance-level token,跳過 web UI token 驗證。 + +### 修復 (Fixed) +- 安裝腳本:自動偵測 sudo、nvm-aware PATH、native modules 用 build-essential、/usr/local/bin symlink 僅 root 執行。 +- Discord:sticker 不再當作 photo attachment;collab 模式 chat log 包含附件檔名。 +- Daemon:統一 log rotation;移除過時的 context rotation 參考。 +- Update:重啟前先終止舊 fleet process;systemctl restart 前執行 daemon-reload。 + +### 效能 (Performance) +- 平行 instance 停止(併發 5)。 +- 交錯重啟通知。 + +## [1.24.0] - 2026-04-21 + +### 新增 (Added) +- **Discord quickstart UX** — plugin 檢查、channel 選擇、options 輸出。 + +### 修復 (Fixed) +- Instance 目錄被外部刪除時健康檢查迴圈會停止。 +- 非數字輸入時 NaN crash;plugin 檢查改用 `npm list -g`。 + +## [1.23.0] - 2026-04-20 + +收束 `docs/fix-plan.md` Phase 1–4 安全/可靠性修復計畫。共 36 項修復/重構,分散於 7 個 PR(#33, #38, #39, #40, #41, #42, #43, #44)。 + +### 安全 (Security) +- **Phase 1 邊界硬化** (PR #33) — 每 instance 獨立 `/agent` token、`/ui/*` 全部 mutation 走 zod 驗證、template 變數消毒、tar entry 驗證、`project_roots` symlink resolve、branch / logPath 防 argument injection、`web.token` 0o600。 +- **Telegram apiRoot 白名單** (P3.3, `9a7b16b`) — 防止透過攻擊者控制的 `apiRoot` 外洩 bot token。 +- **Webhook HMAC-SHA256 簽章** (P3.1, `e65b97c`) — outbound webhook 簽章;接收端可驗證來源。 +- **STT 必須顯式 opt-in** (P3.4, `1fc513e`) — 語音轉文字不再因有 env 就啟用,需 `fleet.yaml` `stt.enabled: true`。 +- **`/update` 安全化** (P3.6, `740c202`, `d38a583`) — 空 `allowed_users` 整個拒絕 `/update`;兩段 token 確認(8 hex、60s TTL);安裝時版本鎖;健康探針失敗自動回滾;supersede 通知。 +- **`access-path` 拒絕 instance 名 path traversal** (P4.3, `d5d41b7`) — 白名單 `^[A-Za-z0-9._-]+$`,拒 `..` / `/` / `\` / NUL。 +- **`.env` 0o600** (P4.4, `49a4328`) — wizard 寫憑證檔加上嚴格權限 + chmod 兜底。 +- **CORS 收緊、支援 Bearer auth** (P3.5, `b180232`) — 拿掉 wildcard CORS;web API 接受 `Authorization: Bearer `。 +- **`paths.ts` md5 → sha256** (P4.5, `1f91c3c`) — 消除 FIPS/掃描器告警。custom `AGEND_HOME` 用戶升級後 tmux session/socket 後綴會變一次。 + +### 修正 (Fixed) +- **Telegram 409 polling 上限** (P3.2, `c67f776`) — retry 上限 30 次,避免無窮 polling。 +- **Topic archiver 持久化** (P2.6, `f134a66`, `42d5d1f`) — archived topic 跨重啟保留,atomic write 至 `/archived-topics.json`。 +- **IPC 單行上限 10MB → 1MB** (P3.7, `d446384`) — overflow 結構化拒絕,避免 OOM。 +- **Tmux pane cache 在 control-mode 重連時清除** (P2.1, `e967bbb`)。 +- **TranscriptMonitor 重入鎖** (P2.4, `65be144`) — 防止重疊的 `pollIncrement`。 +- **Scheduler 啟動時 catch-up 24h 內漏跑** (P2.3, `01e1e32`, `24d6f8a`)。 +- **Cost-guard session rotation 重置 emitted flags** (P2.2, `875a0b2`) — `warnEmitted` / `limitEmitted` 正確重置,rotation 後新 session 不會無聲衝過 daily cap。 +- **SSE dead client 驅逐 + socket error 處理** (P2.5, `ae2a810`) — `broadcastSseEvent` 對單一 dead client 寫入失敗不再 break 整個 loop;`req.on("error")` 在 ECONNRESET 清理 client set。 +- **拿掉 instance 啟動後多餘的 sleep+reconnect** (P2.7, `872547b`) — `startInstance` await 鏈已保證 IPC 就緒。 +- **Cost-guard DST 處理** (P2.8, `3c9ff9f`) — `msUntilMidnight` 改用 `Intl.DateTimeFormat` + 二分搜尋,DST 春令/秋令日不再偏 ±1h。 +- **MessageQueue flood-control backoff 重置** (P3.8, `3474c04`) — drop 後 backoff 真正重置,不會卡在 ~30s。 + +### 變更 (Changed) +- **`fleet-manager.ts` 拆檔** (P4.1, PR #43) — 2842 → 1658 行(-1184)。新增四個模組: + - `fleet-dashboard-html.ts`(442 行)— dashboard HTML 常數 + - `fleet-instructions.ts`(168 行)— `GENERAL_INSTRUCTIONS` + `ensureGeneralInstructions` + - `fleet-rpc-handlers.ts`(387 行)— IPC + HTTP CRUD dispatch + - `fleet-health-server.ts`(326 行)— `startHealthServer` + `getUiStatus` + `extractWebToken` + + 皆採 Context-injection:模組宣告 narrow `XxxContext` interface、FleetManager `implements`、外部以 `this` 呼叫純函數。 +- **`daemon.handleToolCall` 抽出 helper** (P4.2, `e6a9596`) — 抽出 `dispatchFleetRpc(...)`。`handleToolCall` 182 → ~120 行,daemon.ts 淨 -51 行。 +- **`validateTimezone` 單一化** (P4.4, `49a4328`) — `scheduler/scheduler.ts` 移除本地副本,import `config.ts` 的版本。 + +### 文件 (Docs) +- **`docs/fix-plan.md` Phase 1–4 結案** — 所有 P 項目皆 ✅ 或移至 **Deferred / Future Work**(logger rotation、cost-guard tiebreaker 兩項屬 feature 不屬 fix)。 +- **`docs/p4.1-split-plan.md` 歸檔** — 四模組拆檔策略紀錄。 +- **`docs/issue-evaluations.md` 新增** — 對 open issue #24(usage-limit notify)、#8(default topic preset)做效益/tradeoff 分析,供未來規劃用。 + +## [1.22.1] - 2026-04-19 + +### 修正 +- **Discord 附件下載** — `downloadAttachment()` 現在可以正常運作。附件在 `messageCreate` 當下就從 Discord CDN 下載到 `inboxDir`(避開 CDN URL 過期問題),`downloadAttachment()` 改為回傳本地路徑。另外:圖片類附件會被標記為 `photo`(讓 agent 端觸發自動下載)、本地檔名會加上 Discord attachment ID 前綴避免碰撞、同一訊息的多個附件改為並行下載、下載失敗改為 log 而非靜默吞掉,`stop()` 會清理未被消費的暫存檔。關閉 #27。 + ## [1.22.0] - 2026-04-18 ### 新增 diff --git a/docs/cli.md b/docs/cli.md index 02c7b605..e8818a0a 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -18,6 +18,8 @@ agend start # Start AgEnD service (requires install) agend stop # Stop AgEnD service agend restart # Restart AgEnD service agend update # Update AgEnD to latest version and restart +agend update --beta # Install from beta channel instead of latest +agend update --version 1.23.0 # Install a specific version agend update --skip-install # Skip npm install, only restart service agend reload # Hot-reload config (sends SIGHUP to fleet process) ``` diff --git a/docs/cli.zh-TW.md b/docs/cli.zh-TW.md index 9e4273bc..26dee81a 100644 --- a/docs/cli.zh-TW.md +++ b/docs/cli.zh-TW.md @@ -20,6 +20,8 @@ agend start # 啟動 AgEnD 服務(需先安裝) agend stop # 停止 AgEnD 服務 agend restart # 重啟 AgEnD 服務 agend update # 更新 AgEnD 到最新版本並重啟服務 +agend update --beta # 安裝 beta 版本 +agend update --version 1.23.0 # 安裝指定版本 agend reload # 熱讀取配置(重新讀取 fleet.yaml,啟動新實例) ``` diff --git a/docs/commands.md b/docs/commands.md new file mode 100644 index 00000000..35abc546 --- /dev/null +++ b/docs/commands.md @@ -0,0 +1,103 @@ +# Commands Reference + +All slash commands available in Telegram and Discord, organized by platform and mode. + +## Telegram — Fleet Topic Mode (Forum Group) + +Registered via `setMyCommands` with `scope: chat` (forum group only). + +| Command | Description | Permission | Handler | +|---------|-------------|------------|---------| +| `/status` | Show fleet status and costs | All (within fleet access) | Markdown table with instance/backend/ctx/cost/status | +| `/restart` | Graceful restart all instances | Admin (allowed_users) | SIGUSR2 graceful restart | +| `/sysinfo` | System diagnostics | All (within fleet access) | Markdown table with uptime/memory/heap + instance table | +| `/update` | Update AgEnD to latest | Admin (allowed_users) | npm install + detached systemd restart | +| `/doctor` | Run health diagnostics | Admin (allowed_users) | Executes `agend backend doctor` | + +## Telegram — ClassicBot (Private Chats + Groups) + +Registered via `setMyCommands` with `scope: default`. + +| Command | Description | Permission | Notes | +|---------|-------------|------------|-------| +| `/start` | Start an agent in this chat | Private: allowed_users. Group: admin_users | Creates classic instance | +| `/stop` | Stop the agent | admin_users | Stops and unregisters instance | +| `/chat` | Talk to the agent | — | Not implemented for TG classic (use @mention instead) | + +### Telegram ClassicBot — unregistered commands + +These are handled but not shown in the bot menu: + +| Command | Permission | Notes | +|---------|------------|-------| +| `@bot /raw ` | admin_users | Send raw text directly to CLI (bypass /chat wrapper) | +| `@bot ` | All users | Normal conversation trigger via @mention | + +--- + +## Discord — Slash Commands + +Registered globally via `client.application.commands.set()`. + +| Command | Description | Permission | Notes | +|---------|-------------|------------|-------| +| `/start` | Start an agent in this channel | All users | [ClassicBot] Creates classic instance | +| `/stop` | Stop the agent in this channel | All users | [ClassicBot] | +| `/chat ` | Send a message to the agent | All users | [ClassicBot] Required param: message | +| `/compact` | Compact the agent's context window | admin_users | [ClassicBot] | +| `/save ` | Save the agent's conversation | admin_users | [ClassicBot] Optional: --force | +| `/load ` | Load a saved conversation | admin_users | [ClassicBot] | +| `/ctx` | Show agent context usage | All users | [ClassicBot] Shows % used + backend | +| `/collab` | Toggle collaboration mode | admin_users | [ClassicBot] @mention trigger on/off | +| `/update` | Update AgEnD to latest version | admin_users | [Fleet] Notifies user to use CLI | +| `/doctor` | Run health diagnostics | admin_users | [Fleet] Executes `agend backend doctor` | + +--- + +## Permission Model + +### Admin (allowed_users in fleet.yaml) + +Used for fleet-level commands in the **forum group**: +- `/update`, `/doctor` +- Checked against `fleet.yaml` → `channel.access.allowed_users` + +### Admin (admin_users in classicBot.yaml) + +Used for ClassicBot management commands: +- TG: `/start` (groups), `/stop`, `/raw` +- DC: `/compact`, `/save`, `/load`, `/collab` +- Checked against `classicBot.yaml` → `defaults.admin_users` + +### All Users + +No permission check: +- `/status`, `/sysinfo`, `/ctx` +- TG @mention conversation +- DC `/start`, `/stop`, `/chat` + +--- + +## Hidden / Internal Commands + +These are not registered as slash commands but can be typed: + +| Command | Platform | Description | +|---------|----------|-------------| +| `/compact` | TG (via @mention /raw) | Compact context window | +| `/chat save ` | TG (via @mention /raw) | Save session | +| `/chat load ` | TG (via @mention /raw) | Load session | + +--- + +## Command Flow + +``` +User sends /command + → Telegram/Discord adapter emits event + → Fleet Manager routes to handler: + - Forum group → topic-commands.ts (handleGeneralCommand) + - Discord slash → fleet-manager.ts (slash_command handler) + - TG classic → fleet-manager.ts (isTelegramClassic block) + → Handler executes + responds +``` diff --git a/docs/configuration.md b/docs/configuration.md index 9cd29c88..9a56370f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,442 +1,218 @@ -# Configuration +# Configuration Reference -## Fleet config +Complete reference for all AgEnD configuration files. -Located at `~/.agend/fleet.yaml`. Created by `agend init` or manually. +## fleet.yaml -```yaml -project_roots: - - ~/Projects - -channel: - type: telegram - mode: topic - bot_token_env: AGEND_BOT_TOKEN - group_id: -1001234567890 - access: - mode: locked - allowed_users: [123456789] - -defaults: - backend: claude-code - tool_set: standard - startup: - concurrency: 3 - stagger_delay_ms: 2000 - cost_guard: - daily_limit_usd: 50 - warn_at_percentage: 80 - timezone: Asia/Taipei - daily_summary: - enabled: true - hour: 21 - hang_detector: - enabled: true - timeout_minutes: 15 - webhooks: - - url: https://hooks.example.com/agend - events: ["*"] +Located at `~/.agend/fleet.yaml`. The primary configuration file for the fleet. -instances: - my-project: - working_directory: ~/Projects/my-app - description: "Backend API developer" - model: opus - tags: [backend, api] +### Top-level fields -teams: - frontend: - members: [my-project, another-instance] - description: "Frontend development team" - -profiles: - fast-dev: - backend: claude-code - model: sonnet - tool_set: standard - -templates: - code-review: - description: "Code review pipeline" - team: true - instances: - reviewer: - description: "Code reviewer" - model: opus - fixer: - description: "Implements fixes" - profile: fast-dev - -health_port: 19280 -``` +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `channel` | object | no | — | Single channel config (legacy, use `channels[]` for multi-channel) | +| `channels` | ChannelConfig[] | no | — | Multi-channel config array | +| `project_roots` | string[] | no | — | Allowed directories for instance creation | +| `defaults` | object | no | `{}` | Shared defaults applied to all instances | +| `instances` | object | **yes** | — | Per-instance configuration (keyed by instance name) | +| `teams` | object | no | — | Named groups for targeted broadcasting | +| `templates` | object | no | — | Reusable fleet deployment templates | +| `profiles` | object | no | — | Reusable backend/model presets | +| `health_port` | number | no | `19280` | HTTP health endpoint port | --- -## Top-level fields +### channels[] -| Field | Type | Default | Description | -|-------|------|---------|-------------| -| `project_roots` | string[] | `[]` | Directories shown in topic auto-bind browser. Also restricts `create_instance` — working directories must be under a configured root | -| `channel` | object | **required** | Messaging platform configuration | -| `defaults` | object | `{}` | Default settings inherited by all instances | -| `instances` | object | **required** | Instance definitions (key = instance name) | -| `teams` | object | `{}` | Named instance groups for targeted broadcasting | -| `templates` | object | `{}` | Deployment templates for spawning pre-configured instance groups. See [templates](#templates) | -| `profiles` | object | `{}` | Reusable backend/model profiles referenced by templates. See [profiles](#profiles) | -| `workflow` | string \| false | `"builtin"` | Fleet collaboration workflow template. `"builtin"` = standard workflow, `"file:./path.md"` = custom, `false` = disabled | -| `health_port` | number | `19280` | HTTP health/API server port | +Each entry configures a platform adapter (Telegram or Discord). ---- +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `id` | string | multi-channel | type value | Unique identifier for this channel (e.g. "telegram", "discord") | +| `type` | string | **yes** | — | Platform type: `"telegram"` or `"discord"` | +| `mode` | string | **yes** | — | Must be `"topic"` | +| `bot_token_env` | string | **yes** | — | Environment variable name containing the bot token | +| `group_id` | number \| string | no | — | Telegram forum group ID or Discord guild ID | +| `access` | AccessConfig | **yes** | — | Access control settings (see below) | +| `options` | object | no | — | Platform-specific options (e.g. `general_channel_id` for Discord) | +| `telegram_api_root` | string | no | `"https://api.telegram.org"` | Override Telegram Bot API URL | +| `mirror_topic_id` | number \| string | no | — | Topic ID for cross-instance message mirroring | -## channel +#### channel.access | Field | Type | Default | Description | |-------|------|---------|-------------| -| `type` | `"telegram"` \| `"discord"` | **required** | Messaging platform | -| `mode` | `"topic"` | `"topic"` | Routing mode (topic = one topic per instance) | -| `bot_token_env` | string | **required** | Environment variable name holding the bot token | -| `group_id` | number \| string | — | Telegram group ID (negative) or Discord guild ID. Quote Discord snowflake IDs to prevent precision loss. | -| `access` | object | **required** | Access control settings | -| `mirror_topic_id` | number \| string | — | Telegram topic ID for mirroring cross-instance communication. All `send_to_instance` messages appear here | -| `options` | object | — | Platform-specific options (Discord: `category_name`, `general_channel_id`) | +| `mode` | `"locked"` \| `"pairing"` \| `"open"` | `"locked"` | `locked` = whitelist only. `pairing` = self-register via /pair. `open` = all users allowed | +| `allowed_users` | (number\|string)[] | `[]` | Whitelisted user IDs | +| `max_pending_codes` | number | `3` | Max simultaneous pairing codes | +| `code_expiry_minutes` | number | `10` | Pairing code TTL | -### channel.access +#### channel.options (Discord) -| Field | Type | Default | Description | -|-------|------|---------|-------------| -| `mode` | `"locked"` \| `"pairing"` | `"locked"` | `locked` = whitelist only. `pairing` = users can request access via `/pair` command (requires manual code confirmation) | -| `allowed_users` | (number\|string)[] | `[]` | Whitelisted user IDs. Supports both number and string (cross-platform) | -| `max_pending_codes` | number | `3` | Max simultaneous pairing codes (if pairing mode used) | -| `code_expiry_minutes` | number | `10` | Pairing code expiry time | +| Field | Type | Description | +|-------|------|-------------| +| `general_channel_id` | string | Discord channel ID for the General instance | --- -## defaults +### defaults -All fields from `instances.` can be set here as defaults. Additionally: +All fields from `instances.` can be set here as shared defaults. Additional defaults-only fields: | Field | Type | Default | Description | |-------|------|---------|-------------| -| `backend` | string | `"claude-code"` | Default CLI backend for all instances | -| `cost_guard` | object | disabled | Fleet-wide cost guard | -| `hang_detector` | object | enabled, 15min | Hang detection | -| `daily_summary` | object | enabled, 21:00 | Daily cost summary | -| `scheduler` | object | — | Scheduler settings | -| `startup` | object | — | Fleet startup behavior | -| `webhooks` | object[] | `[]` | Webhook notifications | -| `startup_timeout_ms` | number | `25000` | Total CLI backend startup timeout in ms (split 60/40 between output detection and idle wait) | - -### defaults.startup +| `startup.concurrency` | number | `10` | Max instances starting simultaneously | +| `startup.stagger_delay_ms` | number | `500` | Delay between startup groups (ms) | +| `cost_guard.daily_limit_usd` | number | `0` (disabled) | Fleet-wide daily cost limit | +| `cost_guard.warn_at_percentage` | number | `80` | Warn threshold (% of limit) | +| `cost_guard.timezone` | string | system TZ | IANA timezone for daily reset | +| `hang_detector.enabled` | boolean | `true` | Enable stuck instance detection | +| `hang_detector.timeout_minutes` | number | `15` | Minutes of no output before alert | +| `daily_summary.enabled` | boolean | `true` | Enable daily cost/status report | +| `daily_summary.hour` | number | `21` | Report hour (local time) | +| `daily_summary.minute` | number | `0` | Report minute | +| `scheduler.max_schedules` | number | — | Max cron schedules | +| `scheduler.default_timezone` | string | — | Default timezone for schedules | +| `scheduler.retry_count` | number | — | Schedule retry count | +| `scheduler.retry_interval_ms` | number | — | Schedule retry interval | +| `webhooks` | WebhookConfig[] | — | Outbound webhook notifications | -| Field | Type | Default | Description | -|-------|------|---------|-------------| -| `concurrency` | number | `3` | Max instances to start in parallel (1–20) | -| `stagger_delay_ms` | number | `2000` | Delay between instance group starts (0–30000 ms) | - -### defaults.cost_guard - -| Field | Type | Default | Description | -|-------|------|---------|-------------| -| `daily_limit_usd` | number | `0` (disabled) | Daily spending limit per instance. `0` = no limit | -| `warn_at_percentage` | number | `80` | Warn when cost reaches this % of limit | -| `timezone` | string | system TZ | IANA timezone for midnight reset (e.g. `Asia/Taipei`) | - -### defaults.hang_detector - -| Field | Type | Default | Description | -|-------|------|---------|-------------| -| `enabled` | boolean | `true` | Enable idle hang detection | -| `timeout_minutes` | number | `15` | Minutes of no output before hang alert | - -### defaults.daily_summary - -| Field | Type | Default | Description | -|-------|------|---------|-------------| -| `enabled` | boolean | `true` | Enable daily cost summary report | -| `hour` | number | `21` | Hour to send summary (0-23, local time) | -| `minute` | number | `0` | Minute to send summary | +--- -### defaults.scheduler +### instances.\ | Field | Type | Default | Description | |-------|------|---------|-------------| -| `max_schedules` | number | `100` | Maximum number of schedules | -| `default_timezone` | string | `Asia/Taipei` | Default timezone for cron schedules | -| `retry_count` | number | `3` | Retries for failed schedule delivery | -| `retry_interval_ms` | number | `30000` | Retry interval | - -### defaults.webhooks[] - -| Field | Type | Description | -|-------|------|-------------| -| `url` | string | Webhook endpoint URL | -| `events` | string[] | Events to notify (see below). Use `["*"]` for all events | -| `headers` | object | Optional HTTP headers | - -Webhook events: - -| Event | Description | -|-------|-------------| -| `cost_warning` | Instance cost approaches daily limit (warning threshold) | -| `cost_limit` | Instance cost hit daily limit — instance paused | -| `hang` | Instance appears hung (no activity for 15+ minutes) | -| `pty_error` | PTY error detected (rate limit, auth expired, etc.) | -| `pty_recovered` | PTY recovered after error | -| `model_failover` | Rate limit exceeded — switching to fallback model | -| `model_recovered` | Rate limit recovered — restoring primary model | -| `schedule_deferred` | Scheduled task deferred due to rate limiting | +| `working_directory` | string | auto-created | Absolute path to project directory | +| `display_name` | string | — | Agent display name (set by agent) | +| `description` | string | — | Human-readable role description | +| `tags` | string[] | — | Capability tags for discovery | +| `topic_id` | number \| string | auto-created | Telegram topic ID or Discord thread ID | +| `channel_id` | string | — | Bound channel adapter ID (for multi-channel) | +| `general_topic` | boolean | `false` | Mark as General dispatcher instance | +| `backend` | string | `"claude-code"` | CLI backend: `claude-code`, `codex`, `gemini-cli`, `opencode`, `kiro-cli`, `antigravity` | +| `model` | string | — | Model override (format depends on backend) | +| `model_failover` | string[] | — | Ordered fallback models on rate limit | +| `agent_mode` | `"mcp"` \| `"cli"` | `"mcp"` | Communication mode (`"cli"` for antigravity) | +| `tool_set` | string | `"full"` | MCP tool profile: `"full"` (20), `"standard"` (8), `"minimal"` (3) | +| `lightweight` | boolean | `false` | Skip non-essential subsystems | +| `systemPrompt` | string | — | Custom system prompt (supports `file:path` syntax) | +| `workflow` | string \| false | `"builtin"` | Workflow template: `"builtin"`, `"file:path"`, inline, or `false` | +| `skipPermissions` | boolean | — | Skip CLI permission checks | +| `pre_task_command` | string | — | Raw command pasted before each user message | +| `startup_timeout_ms` | number | `25000` | CLI startup timeout (ms) | +| `log_level` | string | `"info"` | `"debug"`, `"info"`, `"warn"`, `"error"` | +| `worktree_source` | string | — | Original repo path (when using git worktree) | +| `cost_guard` | CostGuardConfig | — | Per-instance daily cost limit (overrides fleet) | +| `restart_policy.max_retries` | number | `10` | Max crash restarts | +| `restart_policy.backoff` | string | `"exponential"` | `"exponential"` or `"linear"` | +| `restart_policy.reset_after` | number | `300` | Seconds of uptime before retry count resets | +| `restart_policy.health_check_interval_ms` | number | `30000` | Health check polling interval | +| `context_guardian.grace_period_ms` | number | `600000` | Grace period before context rotation (ms) | +| `context_guardian.max_age_hours` | number | `0` (disabled) | Force rotation after N hours | --- -## teams.\ - -Named groups of instances for targeted broadcasting. Managed via `create_team`, `list_teams`, `update_team`, `delete_team` MCP tools, or defined directly in fleet.yaml. - -| Field | Type | Default | Description | -|-------|------|---------|-------------| -| `members` | string[] | **required** | Instance names in this team | -| `description` | string | — | Human-readable description of the team's purpose | - -Example: +### teams ```yaml teams: - backend-squad: - members: [api-agent, db-agent] - description: "Backend development team" + reviewers: + description: "Code review team" + members: [reviewer-a, reviewer-b] ``` -Use `broadcast(team: "backend-squad", message: "...")` to send to all members. - -> **Note:** When an instance is deleted, it is automatically removed from all teams. - ---- - -## instances.\ - -| Field | Type | Default | Description | -|-------|------|---------|-------------| -| `working_directory` | string | auto | Project directory path. If omitted, auto-created at `~/.agend/workspaces/` | -| `display_name` | string | — | Agent display name (e.g. "Kuro"). Set via `set_display_name` tool | -| `description` | string | — | Role description. Injected via MCP server instructions as `## Role` | -| `tags` | string[] | — | Tags for categorization and capability discovery. Agents can filter by tags in `list_instances` and `broadcast` | -| `topic_id` | number\|string | auto | Channel topic/thread ID. Auto-assigned on create | -| `general_topic` | boolean | `false` | Mark as General Topic (receives unrouted messages) | -| `backend` | string | `"claude-code"` | CLI backend: `claude-code`, `codex`, `gemini-cli`, `opencode`, `kiro-cli` | -| `model` | string | — | Model alias. Claude: `sonnet`, `opus`, `haiku`, `opusplan`, `best`, `sonnet[1m]`, `opus[1m]`. Codex: `gpt-4o`, `o3`. Gemini: `gemini-2.5-pro`. Kiro: `auto`, `claude-sonnet-4.5`, `claude-sonnet-4`, `claude-haiku-4.5` | -| `model_failover` | string[] | — | Fallback models when rate-limited (e.g. `["opus", "sonnet"]`). A 5-minute cooldown prevents repeated failover within the same window | -| `tool_set` | string | `"full"` | MCP tool profile: `full` (all), `standard` (10), `minimal` (4) | -| `agent_mode` | `"mcp"` \| `"cli"` | `"mcp"` | Agent communication mode. `mcp` = standard MCP server (default). `cli` = HTTP endpoint, no MCP server — for backends that don't support MCP | -| `systemPrompt` | string | — | Custom instructions injected via MCP server instructions. Inline string or `file:./path.md` to load from an external file (path relative to `working_directory`). Does not modify the CLI's built-in system prompt. Example: `systemPrompt: "file:./prompts/role.md"` | -| `skipPermissions` | boolean | `true` | Skip CLI permission checks (`--dangerously-skip-permissions`). Set `false` to enable | -| `lightweight` | boolean | `false` | Skip transcript monitor, context guardian, approval server | -| `log_level` | string | `"info"` | `debug`, `info`, `warn`, `error` | -| `restart_policy` | object | see below | Crash recovery settings | -| `context_guardian` | object | see below | Context monitoring settings | -| `cost_guard` | object | — | Per-instance cost guard (overrides defaults) | -| `worktree_source` | string | — | Original repo path (auto-set when using branch parameter) | - -### instances.\.restart_policy - -| Field | Type | Default | Description | -|-------|------|---------|-------------| -| `max_retries` | number | `10` | Max respawn attempts before giving up | -| `backoff` | `"exponential"` \| `"linear"` | `"exponential"` | Backoff strategy between retries | -| `reset_after` | number | `300` | Seconds of stability before resetting retry counter | -| `health_check_interval_ms` | number | `30000` | Health check polling interval in milliseconds | - -### instances.\.context_guardian - -Context monitoring settings. The guardian polls the CLI's statusline for context usage metrics (used for dashboard and logging). Context limits are handled by each CLI's built-in auto-compact — AgEnD does not trigger restarts based on context usage or session age. - -| Field | Type | Default | Description | -|-------|------|---------|-------------| -| `grace_period_ms` | number | `600000` | Vestigial — not read by any code path. Retained for config compatibility | -| `max_age_hours` | number | `0` (disabled) | Vestigial — not read by any code path. CLI auto-compact handles context limits | - -> **Note:** Both fields were part of the original context rotation system, which has been removed. All CLI backends now handle context limits via built-in auto-compact. The ContextGuardian has been reduced to a pure status monitor (polling `statusline.json` for dashboard metrics). +| Field | Type | Description | +|-------|------|-------------| +| `description` | string | Team purpose | +| `members` | string[] | Instance names | --- -## templates.\ - -Deployment templates define pre-configured instance groups that can be deployed on demand via the `deploy_template` MCP tool. Each template spawns a set of instances (optionally with a team) into a target directory. - -| Field | Type | Default | Description | -|-------|------|---------|-------------| -| `description` | string | — | Human-readable template description | -| `team` | boolean | — | Auto-create a team from all deployed instances | -| `instances` | object | **required** | Instance definitions (at least one required) | - -### Template instance fields - -Each instance in a template supports: - -| Field | Type | Description | -|-------|------|-------------| -| `description` | string | Instance role description | -| `backend` | string | CLI backend override | -| `model` | string | Model override | -| `model_failover` | string[] | Fallback models | -| `tool_set` | string | MCP tool profile | -| `systemPrompt` | string | System prompt (inline or `file:` reference) | -| `skipPermissions` | boolean | Skip CLI permission checks | -| `lightweight` | boolean | Lightweight mode | -| `workflow` | string \| false | Workflow template override | -| `tags` | string[] | Instance tags | -| `profile` | string | Reference to a profile in `profiles` section | - -Example: +### templates ```yaml templates: - code-review: - description: "Code review pipeline" + sprint-team: + description: "Sprint development team" team: true instances: + dev: + backend: claude-code + model: sonnet reviewer: - description: "Senior code reviewer" - model: opus - tool_set: standard - implementer: - description: "Implements review feedback" - profile: fast-dev + backend: kiro-cli + tool_set: minimal ``` -Templates are deployed via MCP tools (not CLI commands): -- `deploy_template` — deploy a template into a directory -- `teardown_deployment` — stop and delete all instances from a deployment -- `list_deployments` — list active deployments +| Field | Type | Description | +|-------|------|-------------| +| `description` | string | Template description | +| `team` | boolean | Auto-create team from deployed instances | +| `instances` | object | Instance definitions (same fields as InstanceConfig) | --- -## profiles.\ - -Reusable backend/model configurations referenced by template instances via the `profile` field. +### profiles -| Field | Type | Description | -|-------|------|-------------| -| `backend` | string | CLI backend | -| `model` | string | Model | -| `model_failover` | string[] | Fallback models | -| `tool_set` | string | MCP tool profile | -| `lightweight` | boolean | Lightweight mode | - -Example: +Reusable backend/model presets referenced by template instances via `profile: name`. ```yaml profiles: - fast-dev: - backend: claude-code - model: sonnet - tool_set: standard - heavy-review: + heavy: backend: claude-code model: opus - tool_set: full + light: + backend: kiro-cli + lightweight: true ``` --- -## How fleet context is injected - -AgEnD injects fleet context into each instance via the **MCP server instructions** mechanism — not by modifying the CLI's own system prompt. This keeps each CLI's built-in behavior intact and works uniformly across all backends. - -### Fleet context via MCP instructions - -When the daemon spawns an instance, it starts an MCP server (`agend`) as a child process of the CLI. The daemon passes instance metadata to the MCP server via environment variables: +### webhooks -| Env var | Content | -|---------|---------| -| `AGEND_INSTANCE_NAME` | Instance name (e.g. `my-project`) | -| `AGEND_WORKING_DIR` | Working directory path | -| `AGEND_DISPLAY_NAME` | Agent display name (if set) | -| `AGEND_DESCRIPTION` | Role description from `description` field | -| `AGEND_CUSTOM_PROMPT` | Resolved content from `systemPrompt` field | - -The MCP server assembles these into a single `instructions` string that the CLI reads via the MCP protocol. The instructions include: - -1. **Identity** — instance name, working directory, display name, role -2. **Message format** — how to distinguish user messages (`[user:name]`) from cross-instance messages (`[from:instance-name]`) -3. **Collaboration rules** — use `reply` for users, `send_to_instance` for cross-instance, scope awareness -4. **Tool guidance** — how to use reply, react, edit_message, download_attachment, and fleet tools -5. **Custom prompt** — the `systemPrompt` content from fleet.yaml (supports `file:` prefix) - -This approach means: -- The CLI's built-in system prompt is **never modified** (Claude Code keeps its tool usage instructions, Gemini keeps its skills, etc.) -- Project-level instruction files (CLAUDE.md, AGENTS.md, GEMINI.md) are **not affected** -- All backends (Claude Code, Codex, Gemini CLI, OpenCode, Kiro CLI) use the same injection path - -### Known limitation: OpenCode MCP instructions - -OpenCode (as of v1.3.10) does **not** read the MCP server `instructions` field. It loads MCP tools correctly, but fleet context (identity, message format, collaboration rules, workflow template) is not injected into the OpenCode instance's system prompt. This means OpenCode instances: - -- Have all fleet MCP tools available (reply, send_to_instance, etc.) -- Do **not** automatically know they are fleet instances or how to use fleet message formats -- May not follow collaboration rules or the workflow template - -This is an upstream limitation. Once OpenCode adds support for MCP instructions, no changes to AgEnD are needed — the existing mechanism will work automatically. - -### Known limitation: Kiro CLI MCP instructions (unverified) - -Kiro CLI (as of v1.29.2) has **not been verified** to read the MCP server `instructions` field. If it does not, the same limitation as OpenCode applies: fleet context will not be injected. Kiro CLI supports `.kiro/steering/` files for context injection, but AgEnD uses the unified MCP instructions path for all backends. - -### Session snapshots (context rotation) - -When a context rotation occurs, the daemon saves a snapshot of the previous session (recent messages, tool activity, context usage) to `rotation-state.json`. On the next spawn, the snapshot is delivered as the **first inbound message** with a `[system:session-snapshot]` prefix — not embedded in the system prompt. - -The snapshot file persists on disk (for daemon restart recovery). An in-memory flag prevents re-injection within the same daemon process. - -### Decisions - -Active decisions (from `post_decision`) are **not** preloaded into the prompt. Agents query them on demand using the `list_decisions` tool. - -### fleet.yaml `systemPrompt` - -The `systemPrompt` field in fleet.yaml still works as before: -- Inline string: `systemPrompt: "You are a security reviewer"` -- File reference: `systemPrompt: "file:./prompts/role.md"` (path relative to `working_directory`) - -The only change is the injection channel: content is now delivered via MCP instructions instead of CLI flags like `--system-prompt`. +```yaml +defaults: + webhooks: + - url: https://example.com/hook + events: [instance.started, instance.stopped] + headers: + Authorization: "Bearer token" +``` --- -## Secrets +## classicBot.yaml -Located at `~/.agend/.env`: +Located at `~/.agend/classicBot.yaml`. Manages ClassicBot channels (auto-created on first `/start`). -``` -AGEND_BOT_TOKEN=123456789:AAH... -GROQ_API_KEY=gsk_... # optional, for voice transcription -``` +### defaults -Values in `~/.agend/.env` take priority over inherited shell environment variables. This ensures secrets set in `.env` are not accidentally overridden by variables in the shell profile. +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `backend` | string | `"claude-code"` | Default backend for all classic channels | +| `model` | string | — | Default model for all classic channels | +| `context_lines` | number | `50` | Chat history lines injected before each message (0 = disable) | +| `allowed_guilds` | string[] | `[]` | Discord server IDs allowed to use ClassicBot (empty = all) | +| `allowed_groups` | string[] | `[]` | Telegram group IDs allowed | +| `allowed_users` | string[] | `[]` | User IDs allowed to interact | +| `admin_users` | string[] | `[]` | User IDs with admin access (/start, /stop, /raw, /compact, /save, /load, /collab) | ---- +### channels.\ -## Data directory - -`~/.agend/`: - -| Path | Purpose | -|------|---------| -| `fleet.yaml` | Fleet configuration | -| `.env` | Bot token + API keys | -| `daemon.log` | Fleet daemon log | -| `fleet.pid` | Fleet manager PID | -| `web.token` | Auth token for Web UI dashboard | -| `scheduler.db` | Schedules + decisions + tasks (SQLite) | -| `events.db` | Event log + activity log (SQLite) | -| `access/access.json` | Access control state (topic mode) | -| `instances//` | Per-instance runtime data | -| `instances//channel.sock` | IPC Unix socket | -| `instances//channel.mcp.pid` | MCP server PID (for orphan cleanup) | -| `instances//statusline.json` | Latest CLI status | -| `instances//session-id` | Session ID for `--resume` | -| `instances//window-id` | tmux window ID | -| `instances//daemon.pid` | Instance daemon PID | -| `instances//rotation-state.json` | Context rotation snapshot (consumed on restart) | -| `instances//crash-state.json` | Crash loop state (auto-cleared on next start) | -| `instances//crash-history.jsonl` | Crash history log (append-only) | -| `instances//prev-instructions` | Previous instructions hash (for change detection) | -| `instances//repos/` | Checked-out repo worktrees (auto-cleaned) | +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `name` | string | — | Channel display name | +| `backend` | string | defaults.backend | Backend override | +| `model` | string | defaults.model | Model override | +| `context_lines` | number | defaults.context_lines | Chat history lines override | +| `collab` | boolean | `false` | Collaboration mode (@mention trigger) | +| `pre_task_command` | string | — | Raw command pasted before each message | +| `createdBy` | string | — | User ID who created this channel | +| `createdAt` | string | — | ISO timestamp | + +### Key behaviors + +- **Backend fallback**: channel → `defaults.backend` → `fleet.yaml` defaults → `claude-code` +- **Hot reload**: changes detected every 30 seconds +- **Instance naming**: `classic--` diff --git a/docs/configuration.zh-TW.md b/docs/configuration.zh-TW.md index 0591884c..66ba9b94 100644 --- a/docs/configuration.zh-TW.md +++ b/docs/configuration.zh-TW.md @@ -74,7 +74,7 @@ health_port: 19280 | 欄位 | 型別 | 預設 | 說明 | |------|------|------|------| -| `mode` | `"locked"` \| `"pairing"` | `"locked"` | `locked` = 僅白名單。`pairing` = 使用者可透過 `/pair` 指令申請存取(需手動確認 code) | +| `mode` | `"locked"` \| `"pairing"` \| `"open"` | `"locked"` | `locked` = 僅白名單。`pairing` = 使用者可透過 `/pair` 指令申請存取。`open` = 允許所有使用者,無需白名單 | | `allowed_users` | (number\|string)[] | `[]` | 白名單使用者 ID。支援 number 和 string(跨平台) | | `max_pending_codes` | number | `3` | 同時可有的配對碼數量上限(pairing 模式) | | `code_expiry_minutes` | number | `10` | 配對碼過期時間 | @@ -168,7 +168,7 @@ teams: | `description` | string | — | 角色描述。透過 MCP server instructions 注入為 `## Role` | | `topic_id` | number\|string | 自動 | 頻道 topic/thread ID。建立時自動分配 | | `general_topic` | boolean | `false` | 標記為 General Topic(接收未路由的訊息) | -| `backend` | string | `"claude-code"` | CLI backend:`claude-code`、`codex`、`gemini-cli`、`opencode`、`kiro-cli` | +| `backend` | string | `"claude-code"` | CLI backend:`claude-code`、`codex`、`gemini-cli`、`opencode`、`kiro-cli`、`antigravity` | | `model` | string | — | 模型。Claude:`sonnet`、`opus`、`haiku`、`opusplan`。Codex:`gpt-4o`。Gemini:`gemini-2.5-pro`。Kiro:`auto`、`claude-sonnet-4.5`、`claude-haiku-4.5` | | `model_failover` | string[] | — | 被限速時的備用模型(例:`["opus", "sonnet"]`)。5 分鐘冷卻期,防止同一時間窗口內重複 failover | | `tool_set` | string | `"full"` | MCP tool 設定:`full`(全部)、`standard`(10 個)、`minimal`(4 個) | @@ -274,6 +274,57 @@ GROQ_API_KEY=gsk_... # 選用,語音轉文字 `~/.agend/.env` 的值優先於繼承的 shell 環境變數。這確保 `.env` 中設定的密鑰不會被 shell profile 中的變數意外覆蓋。 +## classicBot.yaml + +ClassicBot 模式使用獨立設定檔 `~/.agend/classicBot.yaml`。首次在 Discord 文字頻道使用 `/start` 時自動建立,也可手動編輯。 + +```yaml +# ClassicBot 設定 +# 可用 backends: claude-code, gemini-cli, codex, opencode, kiro-cli +defaults: + backend: claude-code # 所有 classic channel 的預設 backend + +channels: + general-chat: # Channel key(用於推導 instance 名稱:classic-general-chat) + channelId: "1234567890" # Discord channel ID + backend: gemini-cli # 可選:覆蓋此 channel 的預設 backend + createdBy: "368442276000694273" + createdAt: "2026-04-12T02:00:00Z" + dev-help: + channelId: "9876543210" # 未設定 backend → 使用 defaults.backend + createdBy: "368442276000694273" + createdAt: "2026-04-12T02:10:00Z" +``` + +### 主要行為 + +- **Backend 優先順序**:channel `backend` → `defaults.backend` → `fleet.yaml` `defaults.backend` → `claude-code` +- **自動更新**:`/start` 新增 channel,`/stop` 移除 channel +- **熱載入**:每 30 秒偵測檔案變更 — 修改 backend 後下一次 `/chat` 即生效 +- **Instance 命名**:從 channel key 推導 — `classic-` +- **`agend ls`**:classic instance 會顯示 `(classic)` 標籤 + +### 額外欄位 + +| 欄位 | 類型 | 說明 | +|------|------|------| +| `defaults.model` | string | 所有 classic channel 的預設模型 | +| `defaults.context_lines` | number | 每次訊息前注入的聊天記錄行數(預設 50,設 0 停用) | +| `defaults.allowed_guilds` | string[] | 允許使用 ClassicBot 的 Discord 伺服器 ID(空 = 全部允許) | +| `defaults.allowed_groups` | string[] | 允許使用 ClassicBot 的 Telegram 群組 ID | +| `defaults.allowed_users` | string[] | 允許互動的使用者 ID | +| `defaults.admin_users` | string[] | 擁有管理權限的使用者 ID(/compact、/save、/load) | +| `channels..model` | string | 個別 channel 模型覆蓋 | +| `channels..context_lines` | number | 個別 channel 聊天記錄行數 | + +### 手動管理 + +可手動編輯 `classicBot.yaml`: +- 變更所有 classic channel 的預設 backend +- 覆蓋特定 channel 的 backend +- 移除 channel(等同 `/stop`) +- 新增 channel(下次 reload 時載入,但需重啟 fleet 才會啟動 instance) + --- ## 資料目錄 @@ -283,6 +334,7 @@ GROQ_API_KEY=gsk_... # 選用,語音轉文字 | 路徑 | 用途 | |------|------| | `fleet.yaml` | Fleet 設定檔 | +| `classicBot.yaml` | ClassicBot channel 設定(per-channel backend) | | `.env` | Bot token + API keys | | `daemon.log` | Fleet daemon 日誌 | | `fleet.pid` | Fleet manager PID | diff --git a/docs/features.md b/docs/features.md index 9c9c9f32..c3f43034 100644 --- a/docs/features.md +++ b/docs/features.md @@ -269,7 +269,7 @@ Connect your fleet to Discord instead of (or alongside) Telegram. 1. **Install the Discord plugin:** ```bash - npm install -g @suzuke/agend-plugin-discord + npm install -g @songsid/agend-plugin-discord ``` 2. **Create a Discord bot** at [Discord Developer Portal](https://discord.com/developers/applications): @@ -492,3 +492,50 @@ defaults: ``` Instances sharing the same working directory are serialized within a group to avoid config file races. + +## Antigravity CLI backend + +AgEnD supports Google's Antigravity CLI (`agy`) as a backend. Since agy does not support MCP, it operates in CLI mode (`agent_mode: cli`) by default — using `agend-agent` commands for fleet communication. + +```yaml +instances: + my-agent: + backend: antigravity + # agent_mode defaults to "cli" for antigravity +``` + +### Workspace handling + +Agy rejects working directories under hidden paths (dot-prefixed ancestors like `~/.agend/`). When the workspace is under a hidden path, AgEnD creates a real directory at `~/agend-workspaces//` and uses it as the CWD. Instructions are written to `.agents/agents.md` inside this directory. + +### Trust prompt + +Agy's "Do you trust this folder?" prompt is automatically dismissed on startup. + +## IPC + adapter auto-reconnect + +When network interruptions cause IPC connections or Telegram/Discord adapters to drop, AgEnD automatically recovers: + +- **IPC disconnect**: retries with exponential backoff (3s, 6s, 12s) then every 60 seconds indefinitely. Each cycle checks if the tmux pane is still alive — if dead, respawns the instance. +- **Adapter fatal error**: retries with backoff (5s, 10s, 20s) then every 60 seconds indefinitely. Covers Telegram polling init failures and Discord gateway disconnects. + +Both mechanisms are suppressed during intentional shutdown (`agend stop` / fleet restart). Log spam is limited to one WARN every 10 retry attempts. + +## Parallel instance stop + +Instance shutdown uses concurrency of 5 to speed up `agend fleet stop` and `agend stop`. The systemd timeout is extended accordingly to prevent premature kill during large fleet shutdowns. + +## Beta update channel + +Install pre-release versions with: + +```bash +agend update --beta # Install from @beta npm dist-tag +agend update # Install from @latest (default) +``` + +The CI pipeline automatically publishes with `--tag beta` when the git tag contains `-beta` (e.g., `v1.24.0-beta.1`). + +## PSS memory reporting + +`agend ls` reports memory using PSS (Proportional Set Size) from `/proc//smaps_rollup` instead of RSS. This avoids double-counting shared library pages across the process tree, giving a more accurate picture of actual memory consumption. Falls back to RSS on non-Linux systems. diff --git a/docs/features.zh-TW.md b/docs/features.zh-TW.md index 84826160..71c88e74 100644 --- a/docs/features.zh-TW.md +++ b/docs/features.zh-TW.md @@ -266,7 +266,7 @@ defaults: 1. **安裝 Discord 外掛:** ```bash - npm install -g @suzuke/agend-plugin-discord + npm install -g @songsid/agend-plugin-discord ``` 2. **建立 Discord bot**,前往 [Discord Developer Portal](https://discord.com/developers/applications): @@ -417,3 +417,50 @@ OpenAI Codex 後端支援 session 恢復。當 session-id 檔案存在時,後 ## 內建文字標準化(英文化)(Builtin text standardization) 所有系統生成的文字(排程通知、語音訊息標籤、general instructions、fleet 通知)現在統一為英文。先前部分訊息為中文。 + +## Antigravity CLI 後端 + +AgEnD 支援 Google 的 Antigravity CLI(`agy`)作為後端。由於 agy 不支援 MCP,預設以 CLI 模式(`agent_mode: cli`)運作 — 使用 `agend-agent` 指令進行 fleet 通訊。 + +```yaml +instances: + my-agent: + backend: antigravity + # agent_mode 預設為 "cli"(antigravity 後端) +``` + +### Workspace 處理 + +Agy 拒絕在隱藏路徑(如 `~/.agend/`)下運作。當 workspace 位於隱藏路徑時,AgEnD 會在 `~/agend-workspaces//` 建立真實目錄作為 CWD。Instructions 寫入該目錄下的 `.agents/agents.md`。 + +### Trust 提示 + +Agy 的「Do you trust this folder?」提示會在啟動時自動 dismiss。 + +## IPC + Adapter 自動重連 + +當網路中斷導致 IPC 連線或 Telegram/Discord adapter 斷線時,AgEnD 自動恢復: + +- **IPC 斷線**:指數退避重試(3s、6s、12s)後每 60 秒無限重試。每輪檢查 tmux pane 是否存活 — 若已死亡則自動重啟 instance。 +- **Adapter 致命錯誤**:退避重試(5s、10s、20s)後每 60 秒無限重試。涵蓋 Telegram polling 初始化失敗及 Discord gateway 斷線。 + +兩種機制在正常關閉(`agend stop` / fleet restart)期間自動停止。每 10 次重試僅記錄一條 WARN,避免刷 log。 + +## 平行 Instance 停止 + +Instance 關閉使用併發數 5 加速 `agend fleet stop` 和 `agend stop`。systemd timeout 相應延長,防止大型 fleet 關閉時被過早終止。 + +## Beta 更新頻道 + +安裝預發布版本: + +```bash +agend update --beta # 從 @beta npm dist-tag 安裝 +agend update # 從 @latest 安裝(預設) +``` + +CI pipeline 在 git tag 包含 `-beta` 時自動以 `--tag beta` 發布(例如 `v1.24.0-beta.1`)。 + +## PSS 記憶體報告 + +`agend ls` 使用 PSS(Proportional Set Size)從 `/proc//smaps_rollup` 取值,而非 RSS。避免跨 process tree 重複計算共用函式庫頁面,更準確反映實際記憶體消耗。非 Linux 系統時 fallback 回 RSS。 diff --git a/docs/permissions.md b/docs/permissions.md new file mode 100644 index 00000000..b26c5260 --- /dev/null +++ b/docs/permissions.md @@ -0,0 +1,155 @@ +# Permissions Matrix + +This document details every permission check in AgEnD across platforms and modes. + +## Permission Sources + +| Source | File | Fields | +|--------|------|--------| +| Fleet access | `fleet.yaml` | `channel.access.mode` (locked/open/pairing), `channel.access.allowed_users` | +| ClassicBot admin | `classicBot.yaml` | `defaults.admin_users` | +| ClassicBot guilds | `classicBot.yaml` | `defaults.allowed_guilds` (Discord servers) | +| ClassicBot groups | `classicBot.yaml` | `defaults.allowed_groups` (Telegram groups) | +| ClassicBot users | `classicBot.yaml` | `defaults.allowed_users` (Telegram private chat) | + +--- + +## Topic Mode (Fleet Instances) + +Commands available in forum topics (Telegram) or forum channels (Discord). + +### Telegram Topic Mode + +| Command | Permission Check | Who Can Use | +|---------|-----------------|-------------| +| Send message to topic | `accessManager.isAllowed(userId)` | `allowed_users` (locked), all (open) | +| `/status` | fleet access | Allowed users | +| `/restart` | fleet access | Allowed users | +| `/sysinfo` | fleet access | Allowed users | +| `/ctx` | fleet access | Allowed users | +| `/update` | explicit `allowed_users` check | Allowed users only | +| `/raw ` | fleet access | Allowed users | +| `/pair` | pairing mode only | Anyone (pairing mode) | + +### Discord Topic Mode + +| Command | Permission Check | Who Can Use | +|---------|-----------------|-------------| +| Send message to topic | `accessManager.isAllowed(userId)` | `allowed_users` (locked), all (open) | +| `/status` | fleet access | Allowed users | +| `/restart` | fleet access | Allowed users | +| `/sysinfo` | fleet access | Allowed users | +| `/ctx` | fleet access | Allowed users | +| `/update` | explicit `allowed_users` check | Allowed users only | +| `/raw ` | fleet access | Allowed users | + +--- + +## ClassicBot Mode + +Commands available in regular channels/groups/private chats. + +### Discord ClassicBot (Slash Commands) + +| Command | Permission Check | Who Can Use | +|---------|-----------------|-------------| +| `/start` | `isGuildAllowed(guildId)` | All users in allowed guilds | +| `/stop` | None (beyond active agent) | All users | +| `/chat ` | None (beyond active agent) | All users | +| `/ctx` | None | All users | +| `/compact` | `isAdmin(userId)` | Admin users only | +| `/save ` | `isAdmin(userId)` | Admin users only | +| `/load ` | `isAdmin(userId)` | Admin users only | +| `/collab` | `isAdmin(userId)` | Admin users only | +| `@mention` (collab) | None | All users (when collab enabled) | + +### Telegram ClassicBot — Private Chat + +| Command | Permission Check | Who Can Use | +|---------|-----------------|-------------| +| `/start` | `isUserAllowed(userId)` | Allowed users (empty = all) | +| `/stop` | `isAdmin(userId)` | Admin only | +| Direct message | None (after /start) | All users | + +### Telegram ClassicBot — Group Chat + +| Command | Permission Check | Who Can Use | +|---------|-----------------|-------------| +| `/start` | `isGroupAllowed(chatId)` + `isAdmin(userId)` | Admin in allowed group | +| `/stop` | `isAdmin(userId)` | Admin only | +| `@bot ` | None (after /start) | All users | +| `@bot /raw ` | `isAdmin(userId)` | Admin only | + +--- + +## Access Control Flow + +### Inbound Message Processing (`handleInboundMessage`) + +``` +Message arrives + │ + ├─ isBotMessage? → only collab classic channels pass + │ + ├─ accessManager.isAllowed(userId)? + │ ├─ YES → continue + │ └─ NO → is TG classic candidate? + │ ├─ YES → bypass (classic has own permission system) + │ └─ NO → is classic channel target? → if not, REJECT + │ + ├─ threadId == null (TG classic mode)? + │ ├─ /command@other_bot → IGNORE entirely + │ ├─ /start → isGroupAllowed + isAdmin (group) / isUserAllowed (private) + │ ├─ /stop → isAdmin + │ ├─ @mention /raw → isAdmin + │ └─ @mention (chat) → ALLOW ALL + │ + └─ threadId set (topic mode)? + └─ Route to instance (already passed access control above) +``` + +--- + +## Known Issues & Notes + +1. **Discord `/start` `/stop` have no admin check** — any user in an allowed guild can start/stop agents. By design (guild whitelist = trust boundary). + +2. **TG `/start@other_bot` isolation** — commands with `@suffix` targeting a different bot are ignored entirely (v0.0.22-beta.3+). + +3. **`allowed_guilds: {}` (non-array)** — treated as "allow all" (v0.0.22-beta.2+ defensive fix). + +4. **Fleet access `mode: open`** — bypasses `allowed_users` check for topic mode. ClassicBot has separate permission system. + +5. **TG Group Privacy** — Bot must have Group Privacy disabled in BotFather OR be group admin to receive @mention messages. Platform requirement. + +6. **`defaults.admin_users` empty** — no one is admin (secure default). Must explicitly add user IDs. + +7. **TG private chat `/stop`** — requires admin (same as group). If `admin_users` is empty, no one can `/stop` in private chat. Add yourself to `admin_users` to manage agents. + +--- + +## Configuration Examples + +### Locked fleet + open classicBot (recommended) +```yaml +# fleet.yaml +channel: + access: + mode: locked + allowed_users: ["951494522"] + +# classicBot.yaml +defaults: + admin_users: ["951494522", "368442276000694273"] + # allowed_guilds/groups/users: omitted = allow all +``` + +### Restricted classicBot +```yaml +# classicBot.yaml +defaults: + admin_users: ["951494522"] + allowed_guilds: ["1496407196106494055"] # Discord servers + allowed_groups: ["-5222823063"] # Telegram groups + allowed_users: ["951494522"] # TG private chat +``` diff --git a/docs/plugin-development.md b/docs/plugin-development.md index 4284237b..6a1466da 100644 --- a/docs/plugin-development.md +++ b/docs/plugin-development.md @@ -1,6 +1,6 @@ # Plugin Development Guide -Build channel adapter plugins for AgEnD. This guide uses the Discord adapter (`@suzuke/agend-plugin-discord`) as a reference implementation. +Build channel adapter plugins for AgEnD. This guide uses the Discord adapter (`@songsid/agend-plugin-discord`) as a reference implementation. ## Plugin Architecture @@ -10,7 +10,7 @@ AgEnD's plugin system lets you add new channel adapters (Slack, Matrix, LINE, et fleet.yaml: channel.type: "slack" ↓ factory.ts tries import(): - 1. @suzuke/agend-plugin-slack (scoped official) + 1. @songsid/agend-plugin-slack (scoped official) 2. agend-plugin-slack (community) 3. agend-adapter-slack (legacy) 4. slack (bare name) @@ -41,7 +41,7 @@ npm install -D typescript "build": "tsc" }, "peerDependencies": { - "@suzuke/agend": ">=1.14.0" + "@songsid/agend": ">=1.14.0" }, "dependencies": { "myapp-sdk": "^1.0.0" @@ -50,7 +50,7 @@ npm install -D typescript ``` Key points: -- `peerDependencies` on `@suzuke/agend` — don't bundle AgEnD itself +- `peerDependencies` on `@songsid/agend` — don't bundle AgEnD itself - `"type": "module"` — AgEnD uses ESM - Your channel SDK goes in `dependencies` @@ -78,8 +78,8 @@ Key points: **src/index.ts** — Plugin entry point: ```typescript -import type { ChannelAdapter } from "@suzuke/agend/channel"; -import type { ChannelConfig } from "@suzuke/agend/types"; +import type { ChannelAdapter } from "@songsid/agend/channel"; +import type { ChannelConfig } from "@songsid/agend/types"; import { MyAppAdapter } from "./myapp-adapter.js"; interface AdapterOpts { @@ -115,9 +115,9 @@ import type { PermissionPrompt, Choice, AlertData, -} from "@suzuke/agend/channel"; -import type { AccessManager } from "@suzuke/agend/channel/access-manager"; -import { MessageQueue } from "@suzuke/agend/channel/message-queue"; +} from "@songsid/agend/channel"; +import type { AccessManager } from "@songsid/agend/channel/access-manager"; +import { MessageQueue } from "@songsid/agend/channel/message-queue"; export class MyAppAdapter extends EventEmitter implements ChannelAdapter { readonly type = "myapp"; @@ -231,12 +231,12 @@ Your plugin can import these from the main package: ```typescript // Types -import type { ChannelAdapter, SendOpts, SentMessage, ... } from "@suzuke/agend/channel"; -import type { ChannelConfig } from "@suzuke/agend/types"; +import type { ChannelAdapter, SendOpts, SentMessage, ... } from "@songsid/agend/channel"; +import type { ChannelConfig } from "@songsid/agend/types"; // Utilities -import type { AccessManager } from "@suzuke/agend/channel/access-manager"; -import { MessageQueue } from "@suzuke/agend/channel/message-queue"; +import type { AccessManager } from "@songsid/agend/channel/access-manager"; +import { MessageQueue } from "@songsid/agend/channel/message-queue"; ``` **MessageQueue** handles rate limiting and message ordering. Wrap your send/edit operations: @@ -250,7 +250,7 @@ this.queue = new MessageQueue({ send, edit, sendFile }); AgEnD's `factory.ts` resolves plugins in this order: -1. `@suzuke/agend-plugin-{type}` — Scoped official plugins +1. `@songsid/agend-plugin-{type}` — Scoped official plugins 2. `agend-plugin-{type}` — Community plugins 3. `agend-adapter-{type}` — Legacy naming convention 4. `{type}` — Bare package name diff --git a/install.sh b/install.sh new file mode 100755 index 00000000..54f0670e --- /dev/null +++ b/install.sh @@ -0,0 +1,298 @@ +#!/usr/bin/env bash +# AgEnD Bootstrap Installer +# Usage: curl -fsSL https://songsid.github.io/AgEnD/install.sh | bash +# +# This script: +# 1. Detects OS + architecture +# 2. Checks/installs Node.js >= 20 (via nvm) +# 3. Checks/installs tmux +# 4. Installs agend globally via npm +# 5. Detects backend CLIs +# 6. Runs agend quickstart +# +# Source: https://github.com/songsid/AgEnD/blob/main/website/public/install.sh + +set -euo pipefail + +# ── Helpers ─────────────────────────────────────────────── + +# Default SUDO — empty for root, "sudo" for regular users +if [ "$(id -u)" -eq 0 ]; then + SUDO="" +else + SUDO="sudo" +fi + +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +RED='\033[0;31m' +DIM='\033[2m' +BOLD='\033[1m' +NC='\033[0m' + +info() { echo -e "${GREEN}✓${NC} $1"; } +warn() { echo -e "${YELLOW}!${NC} $1"; } +error() { echo -e "${RED}✗${NC} $1"; exit 1; } +step() { echo -e "\n${BOLD}[$1/$TOTAL] $2${NC}"; } + +command_exists() { command -v "$1" >/dev/null 2>&1; } + +TOTAL=5 + +echo -e "\n${BOLD}═══ AgEnD Installer ═══${NC}\n" + +# ── Step 1: Detect OS ──────────────────────────────────── + +step 1 "Detecting system" + +OS=$(uname -s) +ARCH=$(uname -m) + +case "$OS" in + Darwin) OS_NAME="macOS" ;; + Linux) OS_NAME="Linux" ;; + *) error "Unsupported OS: $OS. AgEnD supports macOS and Linux." ;; +esac + +info "$OS_NAME ($ARCH)" + +# ── WSL Detection ──────────────────────────────────────── + +IS_WSL=false +if [ "$OS" = "Linux" ] && grep -qiE "microsoft|WSL" /proc/version 2>/dev/null; then + IS_WSL=true + warn "WSL environment detected" + + # Check if node resolves to a Windows binary + if command_exists node; then + NODE_BIN_PATH=$(command -v node) + if [[ "$NODE_BIN_PATH" == /mnt/[a-z]/* ]]; then + warn "Windows Node.js detected at $NODE_BIN_PATH — this causes issues in WSL" + warn "Will install a native Linux Node.js via nvm instead" + # Shadow the Windows node so the version check below triggers nvm install + node() { return 1; } + command_exists() { [[ "$1" != "node" ]] && command -v "$1" >/dev/null 2>&1 || return 1; } + fi + fi + + echo -e " ${DIM}Tip: To permanently hide Windows PATH in WSL, add to /etc/wsl.conf:${NC}" + echo -e " ${DIM} [interop]${NC}" + echo -e " ${DIM} appendWindowsPath=false${NC}" +fi + +# Detect package manager +PKG_MGR="" +if command_exists brew; then + PKG_MGR="brew" +elif command_exists apt-get; then + PKG_MGR="apt" +elif command_exists dnf; then + PKG_MGR="dnf" +elif command_exists pacman; then + PKG_MGR="pacman" +fi + +# ── Step 2: Node.js >= 20 ──────────────────────────────── + +step 2 "Checking Node.js" + +NODE_OK=false +if command_exists node; then + NODE_VERSION=$(node -v | sed 's/v//' | cut -d. -f1) + if [ "$NODE_VERSION" -ge 20 ] 2>/dev/null; then + info "Node.js $(node -v) found" + NODE_OK=true + else + warn "Node.js $(node -v) found but >= 20 required" + fi +fi + +if [ "$NODE_OK" = false ]; then + echo " Installing Node.js 22 via nvm..." + + # Install nvm if not present + if [ ! -d "${NVM_DIR:-$HOME/.nvm}" ]; then + curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash + fi + + export NVM_DIR="${NVM_DIR:-$HOME/.nvm}" + # shellcheck source=/dev/null + [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" + + nvm install 22 + nvm use 22 + nvm alias default 22 + + # Restore command_exists if we shadowed it for WSL + if [ "$IS_WSL" = true ]; then + unset -f node 2>/dev/null || true + unset -f command_exists 2>/dev/null || true + command_exists() { command -v "$1" >/dev/null 2>&1; } + fi + + if ! command_exists node; then + error "Failed to install Node.js. Please install manually: https://nodejs.org" + fi + info "Node.js $(node -v) installed via nvm" + + # Ensure nvm loads in login shells (some .bashrc files exit early for non-interactive shells) + ensure_nvm_in_profile() { + local profile_file="" + if [ -f "$HOME/.bash_profile" ]; then + profile_file="$HOME/.bash_profile" + elif [ -f "$HOME/.profile" ]; then + profile_file="$HOME/.profile" + else + profile_file="$HOME/.profile" + fi + + if ! grep -q "NVM_DIR" "$profile_file" 2>/dev/null; then + cat >> "$profile_file" << 'EOF' + +# NVM (added by AgEnD installer) +export NVM_DIR="$HOME/.nvm" +[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" +EOF + info "Added nvm to $profile_file for new shell sessions" + fi + } + ensure_nvm_in_profile +fi + +# Ensure nvm node is first in PATH on WSL +if [ "$IS_WSL" = true ] && [ -d "${NVM_DIR:-$HOME/.nvm}" ]; then + export NVM_DIR="${NVM_DIR:-$HOME/.nvm}" + [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" + NODE_BIN_PATH=$(command -v node 2>/dev/null || true) + if [[ "$NODE_BIN_PATH" == /mnt/[a-z]/* ]]; then + warn "Windows node still first in PATH — nvm node may not be active" + fi +fi + +# ── Step 3: tmux ───────────────────────────────────────── + +step 3 "Checking tmux" + +if command_exists tmux; then + info "tmux $(tmux -V) found" +else + echo " Installing tmux..." + case "$PKG_MGR" in + brew) brew install tmux ;; + apt) sudo apt-get update -qq && sudo apt-get install -y -qq tmux ;; + dnf) sudo dnf install -y tmux ;; + pacman) sudo pacman -S --noconfirm tmux ;; + *) error "Cannot install tmux automatically. Please install manually." ;; + esac + + if ! command_exists tmux; then + error "Failed to install tmux." + fi + info "tmux $(tmux -V) installed" +fi + +# ── Step 4: Install AgEnD ──────────────────────────────── + +step 4 "Installing AgEnD" + +# Ensure build tools are available (needed for native modules like better-sqlite3) +if ! command_exists g++; then + echo " Installing build tools (needed for native modules)..." + case "$PKG_MGR" in + brew) ;; # macOS Xcode CLT usually provides this + apt) $SUDO apt-get update -qq && $SUDO apt-get install -y -qq build-essential python3 ;; + dnf) $SUDO dnf groupinstall -y "Development Tools" ;; + pacman) $SUDO pacman -S --noconfirm base-devel ;; + esac +fi + +if command_exists agend; then + CURRENT=$(agend --version 2>/dev/null || echo "unknown") + warn "AgEnD already installed (${CURRENT}), upgrading..." +fi + +# Detect if npm global dir needs sudo +NPM_PREFIX=$(npm config get prefix 2>/dev/null || echo "/usr/local") +if [ -w "$NPM_PREFIX/lib/node_modules" ] 2>/dev/null || [ -w "$NPM_PREFIX/lib" ] 2>/dev/null; then + SUDO="" +else + SUDO="sudo" +fi + +# Remove old @suzuke/agend if present (bin name conflict) +if npm list -g @suzuke/agend >/dev/null 2>&1; then + warn "Removing old @suzuke/agend to avoid conflicts..." + $SUDO npm uninstall -g @suzuke/agend 2>/dev/null || true +elif command_exists agend; then + AGEND_REAL=$(readlink -f "$(command -v agend)" 2>/dev/null || true) + if [[ "$AGEND_REAL" == */AgEnD/* ]] || [[ "$AGEND_REAL" == *"@suzuke/agend"* ]] || [[ "$AGEND_REAL" == */Projects/* ]]; then + warn "Removing npm-linked @suzuke/agend..." + AGEND_BIN_DIR=$(dirname "$(command -v agend)") + $SUDO rm -f "$AGEND_BIN_DIR/agend" "$AGEND_BIN_DIR/agend-agent" 2>/dev/null || true + NVM_MODULES="$(dirname "$AGEND_BIN_DIR")/lib/node_modules/@suzuke" + $SUDO rm -rf "$NVM_MODULES" 2>/dev/null || true + fi +fi + +$SUDO npm install -g @songsid/agend @songsid/agend-plugin-discord + +if ! command_exists agend; then + error "Installation failed. Try: npm install -g @songsid/agend" +fi +info "AgEnD $(agend --version) installed" +info "Discord plugin $(npm list -g @songsid/agend-plugin-discord --depth=0 2>/dev/null | grep agend-plugin-discord | sed 's/.*@//' || echo 'unknown') installed" + +# ── Ensure binaries are accessible without nvm sourced ──── +# Root user: create symlinks in /usr/local/bin (for systemd, cron, non-login shells) +# Normal user: nvm PATH in .bashrc/.profile is sufficient +AGEND_BIN=$(command -v agend 2>/dev/null) +NODE_BIN=$(command -v node 2>/dev/null) +if [ -n "$AGEND_BIN" ] && [[ "$AGEND_BIN" == */.nvm/* ]] && [ "$(id -u)" -eq 0 ]; then + ln -sf "$AGEND_BIN" /usr/local/bin/agend 2>/dev/null && info "Symlinked agend → /usr/local/bin/agend" || true + ln -sf "$NODE_BIN" /usr/local/bin/node 2>/dev/null && info "Symlinked node → /usr/local/bin/node" || true + KIRO_BIN=$(command -v kiro-cli 2>/dev/null) + if [ -n "$KIRO_BIN" ]; then + ln -sf "$KIRO_BIN" /usr/local/bin/kiro-cli 2>/dev/null && info "Symlinked kiro-cli → /usr/local/bin/kiro-cli" || true + fi +fi + +# ── Step 5: Detect backend ─────────────────────────────── + +step 5 "Detecting AI backend" + +BACKENDS=("claude:Claude Code" "codex:OpenAI Codex" "gemini:Gemini CLI" "opencode:OpenCode" "kiro-cli:Kiro CLI") +FOUND=0 + +for entry in "${BACKENDS[@]}"; do + cmd="${entry%%:*}" + label="${entry#*:}" + if command_exists "$cmd"; then + info "$label found ($cmd)" + FOUND=$((FOUND + 1)) + fi +done + +if [ "$FOUND" -eq 0 ]; then + echo "" + warn "No supported AI backend found." + echo -e " Install Claude Code: ${DIM}curl -fsSL https://claude.ai/install.sh | bash${NC}" + echo "" +fi + +# ── Launch quickstart ───────────────────────────────────── + +echo -e "\n${BOLD}═══ Installation Complete ═══${NC}\n" +echo " Run the setup wizard:" +echo -e " ${BOLD}agend quickstart${NC}\n" + +warn "If 'agend' is not found in a new terminal, run: source ~/.profile" +echo "" + +# Auto-launch if interactive terminal +if [ -t 0 ] && [ -t 1 ]; then + read -rp " Launch quickstart now? [Y/n] " answer + if [ "${answer:-Y}" != "n" ] && [ "${answer:-Y}" != "N" ]; then + echo "" + agend quickstart + fi +fi diff --git a/package-lock.json b/package-lock.json index b8a0fc3d..e981028a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,13 +8,16 @@ "name": "@suzuke/agend", "version": "1.22.0", "license": "MIT", + "workspaces": [ + "plugins/*" + ], "dependencies": { "@modelcontextprotocol/sdk": "^1.27.1", "better-sqlite3": "^12.8.0", "commander": "^14.0.3", "croner": "^10.0.1", "ejs": "^5.0.1", - "grammy": "^1.41.1", + "grammy": "^1.44.0", "js-yaml": "^4.1.1", "pino": "^10.3.1", "pino-pretty": "^13.1.3", @@ -37,6 +40,146 @@ "node": ">=20" } }, + "node_modules/@discordjs/builders": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.14.1.tgz", + "integrity": "sha512-gSKkhXLqs96TCzk66VZuHHl8z2bQMJFGwrXC0f33ngK+FLNau4hU1PYny3DNJfNdSH+gVMzE85/d5FQ2BpcNwQ==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/formatters": "^0.6.2", + "@discordjs/util": "^1.2.0", + "@sapphire/shapeshift": "^4.0.0", + "discord-api-types": "^0.38.40", + "fast-deep-equal": "^3.1.3", + "ts-mixer": "^6.0.4", + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/collection": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.5.3.tgz", + "integrity": "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=16.11.0" + } + }, + "node_modules/@discordjs/formatters": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.2.tgz", + "integrity": "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==", + "license": "Apache-2.0", + "dependencies": { + "discord-api-types": "^0.38.33" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.1.tgz", + "integrity": "sha512-wwQdgjeaoYFiaG+atbqx6aJDpqW7JHAo0HrQkBTbYzM3/PJ3GweQIpgElNcGZ26DCUOXMyawYd0YF7vtr+fZXg==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/collection": "^2.1.1", + "@discordjs/util": "^1.2.0", + "@sapphire/async-queue": "^1.5.3", + "@sapphire/snowflake": "^3.5.5", + "@vladfrangu/async_event_emitter": "^2.4.6", + "discord-api-types": "^0.38.40", + "magic-bytes.js": "^1.13.0", + "tslib": "^2.6.3", + "undici": "6.24.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest/node_modules/@discordjs/collection": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest/node_modules/@sapphire/snowflake": { + "version": "3.5.5", + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.5.tgz", + "integrity": "sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@discordjs/util": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz", + "integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==", + "license": "Apache-2.0", + "dependencies": { + "discord-api-types": "^0.38.33" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/ws": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.3.tgz", + "integrity": "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/collection": "^2.1.0", + "@discordjs/rest": "^2.5.1", + "@discordjs/util": "^1.1.0", + "@sapphire/async-queue": "^1.5.2", + "@types/ws": "^8.5.10", + "@vladfrangu/async_event_emitter": "^2.2.4", + "discord-api-types": "^0.38.1", + "tslib": "^2.6.2", + "ws": "^8.17.0" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/ws/node_modules/@discordjs/collection": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, "node_modules/@emnapi/core": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", @@ -514,9 +657,9 @@ } }, "node_modules/@grammyjs/types": { - "version": "3.25.0", - "resolved": "https://registry.npmjs.org/@grammyjs/types/-/types-3.25.0.tgz", - "integrity": "sha512-iN9i5p+8ZOu9OMxWNcguojQfz4K/PDyMPOnL7PPCON+SoA/F8OKMH3uR7CVUkYfdNe0GCz8QOzAWrnqusQYFOg==", + "version": "3.28.0", + "resolved": "https://registry.npmjs.org/@grammyjs/types/-/types-3.28.0.tgz", + "integrity": "sha512-4JvXCdxRZHCje0M4gHzLwtB4bLno3WD28xd8CNfk4POWIu73BFnSvGeW6OQ5gPem4eYTEwkD9yDaXssixl6tMQ==", "license": "MIT" }, "node_modules/@hono/node-server": { @@ -704,9 +847,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -724,9 +864,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -744,9 +881,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -764,9 +898,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -784,9 +915,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -804,9 +932,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -891,6 +1016,39 @@ "dev": true, "license": "MIT" }, + "node_modules/@sapphire/async-queue": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz", + "integrity": "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@sapphire/shapeshift": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-4.0.0.tgz", + "integrity": "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "lodash": "^4.17.21" + }, + "engines": { + "node": ">=v16" + } + }, + "node_modules/@sapphire/snowflake": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.3.tgz", + "integrity": "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -898,6 +1056,34 @@ "dev": true, "license": "MIT" }, + "node_modules/@suzuke/agend": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/@suzuke/agend/-/agend-1.20.3.tgz", + "integrity": "sha512-EQx2WfgSWy1hLqz/GXG2cIVl32RShMOeM/Wu23RjAqZzLf8HYADQtrIDrDiRPxqo2EjlK/o6aswexdE3i7CGug==", + "license": "MIT", + "peer": true, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.27.1", + "better-sqlite3": "^12.8.0", + "commander": "^14.0.3", + "croner": "^10.0.1", + "ejs": "^5.0.1", + "grammy": "^1.41.1", + "js-yaml": "^4.1.1", + "pino": "^10.3.1", + "pino-pretty": "^13.1.3" + }, + "bin": { + "agend": "dist/cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@suzuke/agend-plugin-discord": { + "resolved": "plugins/agend-plugin-discord", + "link": true + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -962,12 +1148,20 @@ "version": "25.5.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~7.18.0" } }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@vitest/expect": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.0.tgz", @@ -1081,6 +1275,16 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@vladfrangu/async_event_emitter": { + "version": "2.4.7", + "resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.7.tgz", + "integrity": "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -1500,6 +1704,42 @@ "node": ">=8" } }, + "node_modules/discord-api-types": { + "version": "0.38.45", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.45.tgz", + "integrity": "sha512-DiI01i00FPv6n+hXcFkFxK8Y/rFRpKs6U6aP32N4T73nTbj37Eua3H/95TBpLktLWB6xnLXhYDGvyLq6zzYY2w==", + "license": "MIT", + "workspaces": [ + "scripts/actions/documentation" + ] + }, + "node_modules/discord.js": { + "version": "14.26.2", + "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.26.2.tgz", + "integrity": "sha512-feShi+gULJ6R2MAA4/KkCFnkJcuVrROJrKk4czplzq8gE1oqhqgOy9K0Scu44B8oGeWKe04egquzf+ia6VtXAw==", + "license": "Apache-2.0", + "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.40", + "fast-deep-equal": "3.1.3", + "lodash.snakecase": "4.1.1", + "magic-bytes.js": "^1.13.0", + "tslib": "^2.6.3", + "undici": "6.24.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -1960,12 +2200,12 @@ } }, "node_modules/grammy": { - "version": "1.41.1", - "resolved": "https://registry.npmjs.org/grammy/-/grammy-1.41.1.tgz", - "integrity": "sha512-wcHAQ1e7svL3fJMpDchcQVcWUmywhuepOOjHUHmMmWAwUJEIyK5ea5sbSjZd+Gy1aMpZeP8VYJa+4tP+j1YptQ==", + "version": "1.44.0", + "resolved": "https://registry.npmjs.org/grammy/-/grammy-1.44.0.tgz", + "integrity": "sha512-gGVykS5+c5f1tPV97LuU6IDRMawE2NzpwM9pNz58HQ35IZXDnYL3VOLvNzYognPSeBIOSzQXRu5w96V0aY8y8A==", "license": "MIT", "dependencies": { - "@grammyjs/types": "3.25.0", + "@grammyjs/types": "3.28.0", "abort-controller": "^3.0.0", "debug": "^4.4.3", "node-fetch": "^2.7.0" @@ -2296,9 +2536,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2320,9 +2557,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2344,9 +2578,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2368,9 +2599,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2426,6 +2654,24 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash.snakecase": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", + "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", + "license": "MIT" + }, + "node_modules/magic-bytes.js": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.13.0.tgz", + "integrity": "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==", + "license": "MIT" + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -3463,13 +3709,17 @@ "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", "license": "MIT" }, + "node_modules/ts-mixer": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz", + "integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==", + "license": "MIT" + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true + "license": "0BSD" }, "node_modules/tsx": { "version": "4.21.0", @@ -3531,11 +3781,19 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz", + "integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, "node_modules/undici-types": { "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "dev": true, "license": "MIT" }, "node_modules/unpipe": { @@ -3776,6 +4034,27 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "license": "MIT", + "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 + } + } + }, "node_modules/zod": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", @@ -3793,6 +4072,17 @@ "peerDependencies": { "zod": "^3.25.28 || ^4" } + }, + "plugins/agend-plugin-discord": { + "name": "@suzuke/agend-plugin-discord", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "discord.js": "^14.25.1" + }, + "peerDependencies": { + "@suzuke/agend": ">=1.13.0" + } } } } diff --git a/package.json b/package.json index a1405e56..43b83c79 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,9 @@ "version": "1.22.0", "description": "Multi-agent fleet daemon — run any coding CLI (Claude, Gemini, Codex, OpenCode) from Telegram", "type": "module", + "workspaces": [ + "plugins/*" + ], "bin": { "agend": "./dist/cli.js", "agend-agent": "./dist/agent-cli.js" @@ -16,7 +19,7 @@ }, "scripts": { "build": "tsc", - "postbuild": "cp -r src/plugin dist/ && cp -r src/workflow-templates dist/ && cp src/agent-cli-instructions.md dist/ && cp -r src/ui dist/ && cd dist/plugin/agend && ln -sf ../../channel/mcp-server.js server.js", + "postbuild": "cp -r src/plugin dist/ && cp -r src/workflow-templates dist/ && cp src/agent-cli-instructions.md dist/ && cp -r src/ui dist/ && cp -r src/general-knowledge dist/ && cd dist/plugin/agend && ln -sf ../../channel/mcp-server.js server.js", "start": "tsx src/cli.ts start", "dev": "tsx src/cli.ts", "test": "vitest run", @@ -55,7 +58,7 @@ "commander": "^14.0.3", "croner": "^10.0.1", "ejs": "^5.0.1", - "grammy": "^1.41.1", + "grammy": "^1.44.0", "js-yaml": "^4.1.1", "pino": "^10.3.1", "pino-pretty": "^13.1.3", diff --git a/plugins/agend-plugin-discord/package-lock.json b/plugins/agend-plugin-discord/package-lock.json deleted file mode 100644 index 034f9022..00000000 --- a/plugins/agend-plugin-discord/package-lock.json +++ /dev/null @@ -1,2428 +0,0 @@ -{ - "name": "agend-plugin-discord", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "agend-plugin-discord", - "version": "1.0.0", - "license": "MIT", - "dependencies": { - "discord.js": "^14.25.1" - }, - "peerDependencies": { - "@suzuke/agend": ">=1.13.0" - } - }, - "node_modules/@discordjs/builders": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.14.1.tgz", - "integrity": "sha512-gSKkhXLqs96TCzk66VZuHHl8z2bQMJFGwrXC0f33ngK+FLNau4hU1PYny3DNJfNdSH+gVMzE85/d5FQ2BpcNwQ==", - "license": "Apache-2.0", - "dependencies": { - "@discordjs/formatters": "^0.6.2", - "@discordjs/util": "^1.2.0", - "@sapphire/shapeshift": "^4.0.0", - "discord-api-types": "^0.38.40", - "fast-deep-equal": "^3.1.3", - "ts-mixer": "^6.0.4", - "tslib": "^2.6.3" - }, - "engines": { - "node": ">=16.11.0" - }, - "funding": { - "url": "https://github.com/discordjs/discord.js?sponsor" - } - }, - "node_modules/@discordjs/collection": { - "version": "1.5.3", - "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.5.3.tgz", - "integrity": "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==", - "license": "Apache-2.0", - "engines": { - "node": ">=16.11.0" - } - }, - "node_modules/@discordjs/formatters": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.2.tgz", - "integrity": "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==", - "license": "Apache-2.0", - "dependencies": { - "discord-api-types": "^0.38.33" - }, - "engines": { - "node": ">=16.11.0" - }, - "funding": { - "url": "https://github.com/discordjs/discord.js?sponsor" - } - }, - "node_modules/@discordjs/rest": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.1.tgz", - "integrity": "sha512-wwQdgjeaoYFiaG+atbqx6aJDpqW7JHAo0HrQkBTbYzM3/PJ3GweQIpgElNcGZ26DCUOXMyawYd0YF7vtr+fZXg==", - "license": "Apache-2.0", - "dependencies": { - "@discordjs/collection": "^2.1.1", - "@discordjs/util": "^1.2.0", - "@sapphire/async-queue": "^1.5.3", - "@sapphire/snowflake": "^3.5.5", - "@vladfrangu/async_event_emitter": "^2.4.6", - "discord-api-types": "^0.38.40", - "magic-bytes.js": "^1.13.0", - "tslib": "^2.6.3", - "undici": "6.24.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/discordjs/discord.js?sponsor" - } - }, - "node_modules/@discordjs/rest/node_modules/@discordjs/collection": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", - "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", - "license": "Apache-2.0", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/discordjs/discord.js?sponsor" - } - }, - "node_modules/@discordjs/rest/node_modules/@sapphire/snowflake": { - "version": "3.5.5", - "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.5.tgz", - "integrity": "sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ==", - "license": "MIT", - "engines": { - "node": ">=v14.0.0", - "npm": ">=7.0.0" - } - }, - "node_modules/@discordjs/util": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz", - "integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==", - "license": "Apache-2.0", - "dependencies": { - "discord-api-types": "^0.38.33" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/discordjs/discord.js?sponsor" - } - }, - "node_modules/@discordjs/ws": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.3.tgz", - "integrity": "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==", - "license": "Apache-2.0", - "dependencies": { - "@discordjs/collection": "^2.1.0", - "@discordjs/rest": "^2.5.1", - "@discordjs/util": "^1.1.0", - "@sapphire/async-queue": "^1.5.2", - "@types/ws": "^8.5.10", - "@vladfrangu/async_event_emitter": "^2.2.4", - "discord-api-types": "^0.38.1", - "tslib": "^2.6.2", - "ws": "^8.17.0" - }, - "engines": { - "node": ">=16.11.0" - }, - "funding": { - "url": "https://github.com/discordjs/discord.js?sponsor" - } - }, - "node_modules/@discordjs/ws/node_modules/@discordjs/collection": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", - "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", - "license": "Apache-2.0", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/discordjs/discord.js?sponsor" - } - }, - "node_modules/@grammyjs/types": { - "version": "3.26.0", - "resolved": "https://registry.npmjs.org/@grammyjs/types/-/types-3.26.0.tgz", - "integrity": "sha512-jlnyfxfev/2o68HlvAGRocAXgdPPX5QabG7jZlbqC2r9DZyWBfzTlg+nu3O3Fy4EhgLWu28hZ/8wr7DsNamP9A==", - "license": "MIT", - "peer": true - }, - "node_modules/@hono/node-server": { - "version": "1.19.12", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.12.tgz", - "integrity": "sha512-txsUW4SQ1iilgE0l9/e9VQWmELXifEFvmdA1j6WFh/aFPj99hIntrSsq/if0UWyGVkmrRPKA1wCeP+UCr1B9Uw==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=18.14.1" - }, - "peerDependencies": { - "hono": "^4" - } - }, - "node_modules/@modelcontextprotocol/sdk": { - "version": "1.29.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", - "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "@hono/node-server": "^1.19.9", - "ajv": "^8.17.1", - "ajv-formats": "^3.0.1", - "content-type": "^1.0.5", - "cors": "^2.8.5", - "cross-spawn": "^7.0.5", - "eventsource": "^3.0.2", - "eventsource-parser": "^3.0.0", - "express": "^5.2.1", - "express-rate-limit": "^8.2.1", - "hono": "^4.11.4", - "jose": "^6.1.3", - "json-schema-typed": "^8.0.2", - "pkce-challenge": "^5.0.0", - "raw-body": "^3.0.0", - "zod": "^3.25 || ^4.0", - "zod-to-json-schema": "^3.25.1" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@cfworker/json-schema": "^4.1.1", - "zod": "^3.25 || ^4.0" - }, - "peerDependenciesMeta": { - "@cfworker/json-schema": { - "optional": true - }, - "zod": { - "optional": false - } - } - }, - "node_modules/@pinojs/redact": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", - "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", - "license": "MIT", - "peer": true - }, - "node_modules/@sapphire/async-queue": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz", - "integrity": "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==", - "license": "MIT", - "engines": { - "node": ">=v14.0.0", - "npm": ">=7.0.0" - } - }, - "node_modules/@sapphire/shapeshift": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-4.0.0.tgz", - "integrity": "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "lodash": "^4.17.21" - }, - "engines": { - "node": ">=v16" - } - }, - "node_modules/@sapphire/snowflake": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.3.tgz", - "integrity": "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==", - "license": "MIT", - "engines": { - "node": ">=v14.0.0", - "npm": ">=7.0.0" - } - }, - "node_modules/@suzuke/agend": { - "version": "1.13.0", - "resolved": "https://registry.npmjs.org/@suzuke/agend/-/agend-1.13.0.tgz", - "integrity": "sha512-YFFiEjTUtfxa40ORrzxuryW/FuIIbA+LADYaJ9F+r4u0UwELlt9PddJ0M8xWcrRoapssbvKdabXglz6NSyRyzw==", - "license": "MIT", - "peer": true, - "dependencies": { - "@modelcontextprotocol/sdk": "^1.27.1", - "better-sqlite3": "^12.8.0", - "commander": "^14.0.3", - "croner": "^10.0.1", - "discord.js": "^14.25.1", - "ejs": "^5.0.1", - "grammy": "^1.41.1", - "js-yaml": "^4.1.1", - "pino": "^10.3.1", - "pino-pretty": "^13.1.3" - }, - "bin": { - "agend": "dist/cli.js" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@types/node": { - "version": "25.5.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.2.tgz", - "integrity": "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==", - "license": "MIT", - "dependencies": { - "undici-types": "~7.18.0" - } - }, - "node_modules/@types/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@vladfrangu/async_event_emitter": { - "version": "2.4.7", - "resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.7.tgz", - "integrity": "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==", - "license": "MIT", - "engines": { - "node": ">=v14.0.0", - "npm": ">=7.0.0" - } - }, - "node_modules/abort-controller": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", - "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", - "license": "MIT", - "peer": true, - "dependencies": { - "event-target-shim": "^5.0.0" - }, - "engines": { - "node": ">=6.5" - } - }, - "node_modules/accepts": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", - "license": "MIT", - "peer": true, - "dependencies": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", - "license": "MIT", - "peer": true, - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-formats": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", - "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "license": "Python-2.0", - "peer": true - }, - "node_modules/atomic-sleep": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", - "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "peer": true - }, - "node_modules/better-sqlite3": { - "version": "12.8.0", - "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.8.0.tgz", - "integrity": "sha512-RxD2Vd96sQDjQr20kdP+F+dK/1OUNiVOl200vKBZY8u0vTwysfolF6Hq+3ZK2+h8My9YvZhHsF+RSGZW2VYrPQ==", - "hasInstallScript": true, - "license": "MIT", - "peer": true, - "dependencies": { - "bindings": "^1.5.0", - "prebuild-install": "^7.1.1" - }, - "engines": { - "node": "20.x || 22.x || 23.x || 24.x || 25.x" - } - }, - "node_modules/bindings": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", - "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "file-uri-to-path": "1.0.0" - } - }, - "node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "license": "MIT", - "peer": true, - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, - "node_modules/body-parser": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", - "license": "MIT", - "peer": true, - "dependencies": { - "bytes": "^3.1.2", - "content-type": "^1.0.5", - "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", - "on-finished": "^2.4.1", - "qs": "^6.14.1", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "peer": true, - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "license": "MIT", - "peer": true, - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/chownr": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", - "license": "ISC", - "peer": true - }, - "node_modules/colorette": { - "version": "2.0.20", - "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", - "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", - "license": "MIT", - "peer": true - }, - "node_modules/commander": { - "version": "14.0.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", - "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=20" - } - }, - "node_modules/content-disposition": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", - "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6.6.0" - } - }, - "node_modules/cors": { - "version": "2.8.6", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", - "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", - "license": "MIT", - "peer": true, - "dependencies": { - "object-assign": "^4", - "vary": "^1" - }, - "engines": { - "node": ">= 0.10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/croner": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/croner/-/croner-10.0.1.tgz", - "integrity": "sha512-ixNtAJndqh173VQ4KodSdJEI6nuioBWI0V1ITNKhZZsO0pEMoDxz539T4FTTbSZ/xIOSuDnzxLVRqBVSvPNE2g==", - "funding": [ - { - "type": "other", - "url": "https://paypal.me/hexagonpp" - }, - { - "type": "github", - "url": "https://github.com/sponsors/hexagon" - } - ], - "license": "MIT", - "peer": true, - "engines": { - "node": ">=18.0" - } - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "license": "MIT", - "peer": true, - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/dateformat": { - "version": "4.6.3", - "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz", - "integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==", - "license": "MIT", - "peer": true, - "engines": { - "node": "*" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "peer": true, - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/decompress-response": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "mimic-response": "^3.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/deep-extend": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "license": "Apache-2.0", - "peer": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/discord-api-types": { - "version": "0.38.44", - "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.44.tgz", - "integrity": "sha512-q91MgBzP/gRaCLIbQTaOrOhbD8uVIaPKxpgX2sfFB2nZ9nSiTYM9P3NFQ7cbO6NCxctI6ODttc67MI+YhIfILg==", - "license": "MIT", - "workspaces": [ - "scripts/actions/documentation" - ] - }, - "node_modules/discord.js": { - "version": "14.26.2", - "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.26.2.tgz", - "integrity": "sha512-feShi+gULJ6R2MAA4/KkCFnkJcuVrROJrKk4czplzq8gE1oqhqgOy9K0Scu44B8oGeWKe04egquzf+ia6VtXAw==", - "license": "Apache-2.0", - "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.40", - "fast-deep-equal": "3.1.3", - "lodash.snakecase": "4.1.1", - "magic-bytes.js": "^1.13.0", - "tslib": "^2.6.3", - "undici": "6.24.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/discordjs/discord.js?sponsor" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "peer": true, - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "license": "MIT", - "peer": true - }, - "node_modules/ejs": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ejs/-/ejs-5.0.1.tgz", - "integrity": "sha512-COqBPFMxuPTPspXl2DkVYaDS3HtrD1GpzOGkNTJ1IYkifq/r9h8SVEFrjA3D9/VJGOEoMQcrlhpntcSUrM8k6A==", - "license": "Apache-2.0", - "peer": true, - "bin": { - "ejs": "bin/cli.js" - }, - "engines": { - "node": ">=0.12.18" - } - }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/end-of-stream": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", - "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "license": "MIT", - "peer": true, - "dependencies": { - "once": "^1.4.0" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "license": "MIT", - "peer": true, - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT", - "peer": true - }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/event-target-shim": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", - "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/eventsource": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", - "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", - "license": "MIT", - "peer": true, - "dependencies": { - "eventsource-parser": "^3.0.1" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/eventsource-parser": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", - "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/expand-template": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", - "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", - "license": "(MIT OR WTFPL)", - "peer": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/express": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", - "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", - "license": "MIT", - "peer": true, - "dependencies": { - "accepts": "^2.0.0", - "body-parser": "^2.2.1", - "content-disposition": "^1.0.0", - "content-type": "^1.0.5", - "cookie": "^0.7.1", - "cookie-signature": "^1.2.1", - "debug": "^4.4.0", - "depd": "^2.0.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "finalhandler": "^2.1.0", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "merge-descriptors": "^2.0.0", - "mime-types": "^3.0.0", - "on-finished": "^2.4.1", - "once": "^1.4.0", - "parseurl": "^1.3.3", - "proxy-addr": "^2.0.7", - "qs": "^6.14.0", - "range-parser": "^1.2.1", - "router": "^2.2.0", - "send": "^1.1.0", - "serve-static": "^2.2.0", - "statuses": "^2.0.1", - "type-is": "^2.0.1", - "vary": "^1.1.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/express-rate-limit": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz", - "integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==", - "license": "MIT", - "peer": true, - "dependencies": { - "ip-address": "10.1.0" - }, - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://github.com/sponsors/express-rate-limit" - }, - "peerDependencies": { - "express": ">= 4.11" - } - }, - "node_modules/fast-copy": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.2.tgz", - "integrity": "sha512-ybA6PDXIXOXivLJK/z9e+Otk7ve13I4ckBvGO5I2RRmBU1gMHLVDJYEuJYhGwez7YNlYji2M2DvVU+a9mSFDlw==", - "license": "MIT", - "peer": true - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "license": "MIT" - }, - "node_modules/fast-safe-stringify": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", - "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", - "license": "MIT", - "peer": true - }, - "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause", - "peer": true - }, - "node_modules/file-uri-to-path": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", - "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", - "license": "MIT", - "peer": true - }, - "node_modules/finalhandler": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", - "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", - "license": "MIT", - "peer": true, - "dependencies": { - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "on-finished": "^2.4.1", - "parseurl": "^1.3.3", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fresh": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/fs-constants": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", - "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", - "license": "MIT", - "peer": true - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "peer": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "peer": true, - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/github-from-package": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", - "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", - "license": "MIT", - "peer": true - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/grammy": { - "version": "1.42.0", - "resolved": "https://registry.npmjs.org/grammy/-/grammy-1.42.0.tgz", - "integrity": "sha512-1AdCge+AkjSdp2FwfICSFnVbl8Mq3KVHJDy+DgTI9+D6keJ0zWALPRKas5jv/8psiCzL4N2cEOcGW7O45Kn39g==", - "license": "MIT", - "peer": true, - "dependencies": { - "@grammyjs/types": "3.26.0", - "abort-controller": "^3.0.0", - "debug": "^4.4.3", - "node-fetch": "^2.7.0" - }, - "engines": { - "node": "^12.20.0 || >=14.13.1" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/help-me": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz", - "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==", - "license": "MIT", - "peer": true - }, - "node_modules/hono": { - "version": "4.12.10", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.10.tgz", - "integrity": "sha512-mx/p18PLy5og9ufies2GOSUqep98Td9q4i/EF6X7yJgAiIopxqdfIO3jbqsi3jRgTgw88jMDEzVKi+V2EF+27w==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=16.9.0" - } - }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/iconv-lite": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", - "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", - "license": "MIT", - "peer": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause", - "peer": true - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC", - "peer": true - }, - "node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "license": "ISC", - "peer": true - }, - "node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 12" - } - }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/is-promise": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "license": "MIT", - "peer": true - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC", - "peer": true - }, - "node_modules/jose": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", - "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", - "license": "MIT", - "peer": true, - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, - "node_modules/joycon": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", - "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "license": "MIT", - "peer": true, - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT", - "peer": true - }, - "node_modules/json-schema-typed": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", - "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", - "license": "BSD-2-Clause", - "peer": true - }, - "node_modules/lodash": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", - "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", - "license": "MIT" - }, - "node_modules/lodash.snakecase": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", - "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", - "license": "MIT" - }, - "node_modules/magic-bytes.js": { - "version": "1.13.0", - "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.13.0.tgz", - "integrity": "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==", - "license": "MIT" - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/media-typer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/merge-descriptors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "license": "MIT", - "peer": true, - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/mimic-response": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "license": "MIT", - "peer": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/mkdirp-classic": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", - "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", - "license": "MIT", - "peer": true - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT", - "peer": true - }, - "node_modules/napi-build-utils": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", - "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", - "license": "MIT", - "peer": true - }, - "node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/node-abi": { - "version": "3.89.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz", - "integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==", - "license": "MIT", - "peer": true, - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "license": "MIT", - "peer": true, - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/on-exit-leak-free": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", - "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "license": "MIT", - "peer": true, - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "license": "ISC", - "peer": true, - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/path-to-regexp": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", - "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", - "license": "MIT", - "peer": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/pino": { - "version": "10.3.1", - "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", - "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", - "license": "MIT", - "peer": true, - "dependencies": { - "@pinojs/redact": "^0.4.0", - "atomic-sleep": "^1.0.0", - "on-exit-leak-free": "^2.1.0", - "pino-abstract-transport": "^3.0.0", - "pino-std-serializers": "^7.0.0", - "process-warning": "^5.0.0", - "quick-format-unescaped": "^4.0.3", - "real-require": "^0.2.0", - "safe-stable-stringify": "^2.3.1", - "sonic-boom": "^4.0.1", - "thread-stream": "^4.0.0" - }, - "bin": { - "pino": "bin.js" - } - }, - "node_modules/pino-abstract-transport": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", - "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", - "license": "MIT", - "peer": true, - "dependencies": { - "split2": "^4.0.0" - } - }, - "node_modules/pino-pretty": { - "version": "13.1.3", - "resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-13.1.3.tgz", - "integrity": "sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==", - "license": "MIT", - "peer": true, - "dependencies": { - "colorette": "^2.0.7", - "dateformat": "^4.6.3", - "fast-copy": "^4.0.0", - "fast-safe-stringify": "^2.1.1", - "help-me": "^5.0.0", - "joycon": "^3.1.1", - "minimist": "^1.2.6", - "on-exit-leak-free": "^2.1.0", - "pino-abstract-transport": "^3.0.0", - "pump": "^3.0.0", - "secure-json-parse": "^4.0.0", - "sonic-boom": "^4.0.1", - "strip-json-comments": "^5.0.2" - }, - "bin": { - "pino-pretty": "bin.js" - } - }, - "node_modules/pino-std-serializers": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", - "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", - "license": "MIT", - "peer": true - }, - "node_modules/pkce-challenge": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", - "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/prebuild-install": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", - "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", - "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", - "license": "MIT", - "peer": true, - "dependencies": { - "detect-libc": "^2.0.0", - "expand-template": "^2.0.3", - "github-from-package": "0.0.0", - "minimist": "^1.2.3", - "mkdirp-classic": "^0.5.3", - "napi-build-utils": "^2.0.0", - "node-abi": "^3.3.0", - "pump": "^3.0.0", - "rc": "^1.2.7", - "simple-get": "^4.0.0", - "tar-fs": "^2.0.0", - "tunnel-agent": "^0.6.0" - }, - "bin": { - "prebuild-install": "bin.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/process-warning": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", - "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "peer": true - }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "license": "MIT", - "peer": true, - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/pump": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", - "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", - "license": "MIT", - "peer": true, - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, - "node_modules/qs": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", - "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", - "license": "BSD-3-Clause", - "peer": true, - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/quick-format-unescaped": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", - "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", - "license": "MIT", - "peer": true - }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/raw-body": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", - "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", - "license": "MIT", - "peer": true, - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.7.0", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/rc": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", - "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", - "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", - "peer": true, - "dependencies": { - "deep-extend": "^0.6.0", - "ini": "~1.3.0", - "minimist": "^1.2.0", - "strip-json-comments": "~2.0.1" - }, - "bin": { - "rc": "cli.js" - } - }, - "node_modules/rc/node_modules/strip-json-comments": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "license": "MIT", - "peer": true, - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/real-require": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", - "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 12.13.0" - } - }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/router": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", - "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "debug": "^4.4.0", - "depd": "^2.0.0", - "is-promise": "^4.0.0", - "parseurl": "^1.3.3", - "path-to-regexp": "^8.0.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "peer": true - }, - "node_modules/safe-stable-stringify": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", - "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT", - "peer": true - }, - "node_modules/secure-json-parse": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", - "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause", - "peer": true - }, - "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "license": "ISC", - "peer": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/send": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", - "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "debug": "^4.4.3", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "fresh": "^2.0.0", - "http-errors": "^2.0.1", - "mime-types": "^3.0.2", - "ms": "^2.1.3", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "statuses": "^2.0.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/serve-static": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", - "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", - "license": "MIT", - "peer": true, - "dependencies": { - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "parseurl": "^1.3.3", - "send": "^1.2.0" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "license": "ISC", - "peer": true - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "license": "MIT", - "peer": true, - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "license": "MIT", - "peer": true, - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "license": "MIT", - "peer": true, - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "license": "MIT", - "peer": true, - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "license": "MIT", - "peer": true, - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/simple-concat": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", - "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "peer": true - }, - "node_modules/simple-get": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", - "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "peer": true, - "dependencies": { - "decompress-response": "^6.0.0", - "once": "^1.3.1", - "simple-concat": "^1.0.0" - } - }, - "node_modules/sonic-boom": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", - "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", - "license": "MIT", - "peer": true, - "dependencies": { - "atomic-sleep": "^1.0.0" - } - }, - "node_modules/split2": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", - "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", - "license": "ISC", - "peer": true, - "engines": { - "node": ">= 10.x" - } - }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "license": "MIT", - "peer": true, - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, - "node_modules/strip-json-comments": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz", - "integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/tar-fs": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", - "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "chownr": "^1.1.1", - "mkdirp-classic": "^0.5.2", - "pump": "^3.0.0", - "tar-stream": "^2.1.4" - } - }, - "node_modules/tar-stream": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", - "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "bl": "^4.0.3", - "end-of-stream": "^1.4.1", - "fs-constants": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/thread-stream": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz", - "integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==", - "license": "MIT", - "peer": true, - "dependencies": { - "real-require": "^0.2.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=0.6" - } - }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "license": "MIT", - "peer": true - }, - "node_modules/ts-mixer": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz", - "integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==", - "license": "MIT" - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" - }, - "node_modules/tunnel-agent": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "safe-buffer": "^5.0.1" - }, - "engines": { - "node": "*" - } - }, - "node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", - "license": "MIT", - "peer": true, - "dependencies": { - "content-type": "^1.0.5", - "media-typer": "^1.1.0", - "mime-types": "^3.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/undici": { - "version": "6.24.1", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz", - "integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==", - "license": "MIT", - "engines": { - "node": ">=18.17" - } - }, - "node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "license": "MIT" - }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "license": "MIT", - "peer": true - }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "license": "BSD-2-Clause", - "peer": true - }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "license": "MIT", - "peer": true, - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "license": "ISC", - "peer": true, - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC", - "peer": true - }, - "node_modules/ws": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", - "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", - "license": "MIT", - "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 - } - } - }, - "node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", - "license": "MIT", - "peer": true, - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, - "node_modules/zod-to-json-schema": { - "version": "3.25.2", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", - "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", - "license": "ISC", - "peer": true, - "peerDependencies": { - "zod": "^3.25.28 || ^4" - } - } - } -} diff --git a/plugins/agend-plugin-discord/package.json b/plugins/agend-plugin-discord/package.json index 44b5b9f3..cce5ca17 100644 --- a/plugins/agend-plugin-discord/package.json +++ b/plugins/agend-plugin-discord/package.json @@ -14,6 +14,11 @@ "dependencies": { "discord.js": "^14.25.1" }, - "keywords": ["agend", "plugin", "discord", "adapter"], + "keywords": [ + "agend", + "plugin", + "discord", + "adapter" + ], "license": "MIT" } diff --git a/plugins/agend-plugin-discord/src/discord-adapter.ts b/plugins/agend-plugin-discord/src/discord-adapter.ts index c67fdd48..2adaf87e 100644 --- a/plugins/agend-plugin-discord/src/discord-adapter.ts +++ b/plugins/agend-plugin-discord/src/discord-adapter.ts @@ -15,7 +15,6 @@ import { type TextChannel, type Message, type Interaction, - type GuildChannelCreateOptions, } from "discord.js"; import type { ChannelAdapter, @@ -51,10 +50,13 @@ export class DiscordAdapter extends EventEmitter implements ChannelAdapter { private accessManager: AccessManager; private inboxDir: string; private guildId: string; + private openChannels = new Set(); private categoryName: string; private generalChannelId?: string; private queue: MessageQueue; private lastChatId: string | null = null; + private attachmentUrls = new Map(); + private categoryIdPromise?: Promise; constructor(opts: DiscordAdapterOptions) { super(); @@ -107,31 +109,88 @@ export class DiscordAdapter extends EventEmitter implements ChannelAdapter { private _registerHandlers(): void { this.client.on("messageCreate", async (msg: Message) => { - if (msg.author.bot) return; - if (msg.guildId !== this.guildId) return; + if (msg.author.id === this.client.user?.id) return; // Ignore own messages + if (!msg.guildId) return; + if (msg.guildId !== this.guildId) { + if (!this.openChannels.has(msg.channelId)) return; + console.log(`[discord] classic channel message from non-primary guild ${msg.guildId} channel ${msg.channelId}`); + } const userId = msg.author.id; - // Access control — Discord snowflake IDs are strings, keep as-is to avoid precision loss - if (!this.accessManager.isAllowed(userId)) { - return; - } + // Access control moved to fleet-manager to allow classic channels for all users const chatId = this.guildId; const threadId = msg.channelId; const messageId = msg.id; const username = msg.author.username; - const text = msg.content; + let text = msg.content; + + // Handle forwarded messages (messageSnapshots) and embeds + if (!text) { + const parts: string[] = []; + // Forwarded message snapshots (Discord forward feature) + if ((msg as any).messageSnapshots?.size > 0) { + for (const [, snap] of (msg as any).messageSnapshots) { + if (snap.message?.content) parts.push(snap.message.content); + if (snap.message?.embeds?.length) { + for (const e of snap.message.embeds) { + if (e.title) parts.push(e.title); + if (e.description) parts.push(e.description); + } + } + // Forward attachments (images, files) into the main message + if (snap.message?.attachments?.size > 0) { + for (const [, att] of snap.message.attachments) { + msg.attachments.set(att.id, att); + } + } + } + } + // Rich embeds (links, bot messages, etc.) + if (parts.length === 0 && msg.embeds.length > 0) { + for (const e of msg.embeds) { + if (e.title) parts.push(e.title); + if (e.description) parts.push(e.description); + if (e.fields?.length) { + for (const f of e.fields) parts.push(`${f.name}: ${f.value}`); + } + } + } + if (parts.length > 0) text = parts.join("\n"); + } + const isBotMessage = msg.author.bot; // Collect attachments const attachments = msg.attachments.map((att) => ({ - kind: "document" as const, + kind: (att.contentType?.startsWith("image/") ? "photo" + : att.contentType?.startsWith("video/") ? "video" + : att.contentType?.startsWith("audio/") ? "audio" + : "document") as "photo" | "video" | "audio" | "document", fileId: att.id, mime: att.contentType ?? undefined, size: att.size, filename: att.name ?? undefined, })); + // Store attachment URLs for later download + for (const att of msg.attachments.values()) { + this.attachmentUrls.set(att.id, att.url); + } + while (this.attachmentUrls.size > 1000) { + const first = this.attachmentUrls.keys().next().value; + if (first) this.attachmentUrls.delete(first); + else break; + } + + let replyToText: string | undefined; + if (msg.reference?.messageId) { + try { + const ref = await msg.fetchReference(); + replyToText = ref.content || ref.embeds?.[0]?.description || undefined; + } catch { /* deleted message or no permission */ } + } + this.emit("message", { source: "discord", adapterId: this.id, @@ -142,35 +201,93 @@ export class DiscordAdapter extends EventEmitter implements ChannelAdapter { username, text, timestamp: msg.createdAt, + isBotMessage, attachments: attachments.length > 0 ? attachments : undefined, replyTo: msg.reference?.messageId ?? undefined, + replyToText, }); }); - // Handle button interactions - // Trust boundary: deferUpdate() can throw DiscordAPIError[10062] if the + // Handle button interactions and slash commands + // Trust boundary: interaction responses can throw DiscordAPIError[10062] if the // interaction expires (>3s). Catch to prevent crashing the entire daemon. this.client.on("interactionCreate", async (interaction: Interaction) => { try { - if (!interaction.isButton()) return; + if (interaction.guildId !== this.guildId) { + // Allow slash commands through — guild whitelist is checked by fleet-manager. + // Only block non-slash interactions (buttons) from unknown channels. + if (!interaction.isChatInputCommand() && !this.openChannels.has(interaction.channelId ?? "")) return; + if (!interaction.isChatInputCommand()) { + console.log(`[discord] classic channel interaction from non-primary guild ${interaction.guildId} channel ${interaction.channelId}`); + } + } - await interaction.deferUpdate(); + if (interaction.isButton()) { + await interaction.deferUpdate(); + this.emit("callback_query", { + callbackData: interaction.customId, + chatId: this.guildId, + threadId: interaction.channelId, + messageId: interaction.message.id, + }); + return; + } - this.emit("callback_query", { - callbackData: interaction.customId, - chatId: this.guildId, - threadId: interaction.channelId, - messageId: interaction.message.id, - }); + if (interaction.isChatInputCommand()) { + const channelName = interaction.channel && "name" in interaction.channel ? (interaction.channel.name ?? "") : ""; + const username = interaction.user.username; + if (interaction.commandName === "chat") { + const text = interaction.options.getString("message") ?? ""; + await interaction.deferReply(); + this.emit("slash_command", { + command: "chat", + channelId: interaction.channelId, + channelName, + guildId: interaction.guildId ?? undefined, + userId: interaction.user.id, + username, + text, + respond: async (reply: string) => { try { const m = await interaction.editReply(reply); return m.id; } catch { return undefined; } }, + }); + } else { + await interaction.deferReply({ ephemeral: true }); + // Extract options as key-value pairs for fleet-manager + const options: Record = {}; + for (const opt of interaction.options.data) { + options[opt.name] = opt.value as string | boolean; + } + this.emit("slash_command", { + command: interaction.commandName, + channelId: interaction.channelId, + channelName, + guildId: interaction.guildId ?? undefined, + userId: interaction.user.id, + username, + options, + respond: async (reply: string) => { try { await interaction.editReply(reply); } catch { /* expired */ } }, + }); + } + } } catch (err) { console.warn(`[discord] interactionCreate error (${(err as Error).message})`); } }); // Handle channel deletion (equivalent to topic_closed) + this.client.on("guildCreate", (guild) => { + this.emit("new_group_detected", { + groupId: guild.id, + groupTitle: guild.name, + source: "discord", + }); + }); + this.client.on("channelDelete", (channel) => { if (!("guildId" in channel)) return; - if (channel.guildId !== this.guildId) return; + if (channel.guildId !== this.guildId) { + if (!this.openChannels.has(channel.id)) return; + console.log(`[discord] classic channel deleted from non-primary guild ${channel.guildId} channel ${channel.id}`); + } this.emit("topic_closed", { chatId: this.guildId, threadId: channel.id, @@ -178,13 +295,48 @@ export class DiscordAdapter extends EventEmitter implements ChannelAdapter { }); } + /** Mark channels as open (skip access control) — used for classic bot channels */ + setOpenChannels(channelIds: string[]): void { + this.openChannels = new Set(channelIds); + console.log(`[AgEnD] setOpenChannels: ${channelIds.length} channels`, channelIds); + } + // ── Lifecycle ────────────────────────────────────────────────────────── async start(): Promise { this.queue.start(); - this.client.once("ready", () => { - this.emit("started", this.client.user?.username ?? "discord-bot"); + this.client.once("ready", async () => { + // Register classic bot slash commands + try { + await this.client.application?.commands.set([ + { name: "start", description: "[ClassicBot] Start an agent in this channel" }, + { name: "stop", description: "[ClassicBot] Stop the agent in this channel" }, + { + name: "chat", description: "[ClassicBot] Send a message to the agent", + options: [{ name: "message", description: "Your message", type: 3, required: true }], + }, + { name: "compact", description: "[ClassicBot] Compact the agent's context window" }, + { + name: "save", description: "[ClassicBot] Save the agent's conversation", + options: [ + { name: "filename", description: "File name to save as", type: 3, required: true }, + { name: "force", description: "Overwrite if file exists", type: 5, required: false }, + ], + }, + { + name: "load", description: "[ClassicBot] Load a saved conversation", + options: [{ name: "filename", description: "File name to load", type: 3, required: true }], + }, + { name: "ctx", description: "[ClassicBot] Show agent context usage" }, + { name: "collab", description: "[ClassicBot] Toggle collaboration mode (@mention trigger)" }, + { name: "update", description: "[Fleet] Update AgEnD to latest version" }, + { name: "doctor", description: "[Fleet] Run health diagnostics" }, + ]); + } catch (err) { + // Non-fatal — slash commands may fail on network issues + } + this.emit("started", this.client.user?.username ?? "discord-bot", this.client.user?.id); }); await this.client.login(this.botToken); @@ -258,20 +410,11 @@ export class DiscordAdapter extends EventEmitter implements ChannelAdapter { async react(chatId: string, messageId: string, emoji: string): Promise { try { - const guild = await this.client.guilds.fetch(this.guildId); - const channels = guild.channels.cache.filter( - (c: { type: ChannelType }) => c.type === ChannelType.GuildText, + // Direct REST call — single API request instead of 3 (fetchChannel → fetchMessage → react) + const encoded = encodeURIComponent(emoji); + await (this.client as any).rest.put( + `/channels/${chatId}/messages/${messageId}/reactions/${encoded}/@me` ); - for (const [, ch] of channels) { - try { - const textCh = ch as TextChannel; - const msg = await textCh.messages.fetch(messageId); - await msg.react(emoji); - return; - } catch { - continue; - } - } } catch { // No-op per degradation strategy } @@ -369,12 +512,17 @@ export class DiscordAdapter extends EventEmitter implements ChannelAdapter { // ── File download ────────────────────────────────────────────────────── async downloadAttachment(fileId: string): Promise { - // Discord attachment fileId is the attachment ID. We need to find the URL. - // Since Discord attachments include URLs directly, we'll search for the message - // containing this attachment. For MVP, we store the URL in the attachment metadata. - // Here we try to download via the Discord CDN URL pattern. - // In practice, the inbound message handler should store the URL. - throw new Error("downloadAttachment not yet implemented for Discord — use attachment URL directly"); + const url = this.attachmentUrls.get(fileId); + if (!url) throw new Error(`No URL for attachment: ${fileId}`); + const response = await fetch(url); + if (!response.ok) throw new Error(`Download failed: ${response.status}`); + const filename = `${Date.now()}-${fileId.slice(-8)}-${url.split("/").pop()?.split("?")[0] ?? "file"}`; + const localPath = join(this.inboxDir, filename); + const dest = createWriteStream(localPath); + const body = response.body; + if (!body) throw new Error("No response body"); + await pipeline(Readable.fromWeb(body as import("stream/web").ReadableStream), dest); + return localPath; } // ── Intent-oriented methods ────────────────────────────────────────── @@ -420,29 +568,55 @@ export class DiscordAdapter extends EventEmitter implements ChannelAdapter { // ── Topology: create channel ──────────────────────────────────────────── - async createTopic(name: string): Promise { + private async _resolveCategory(): Promise { const guild = await this.client.guilds.fetch(this.guildId); - - // Find or create the category - let category = guild.channels.cache.find( + await guild.channels.fetch(); + const existing = guild.channels.cache.find( (c: { type: ChannelType; name: string }) => c.type === ChannelType.GuildCategory && c.name === this.categoryName, ); + if (existing) return existing.id; + const cat = await guild.channels.create({ + name: this.categoryName, + type: ChannelType.GuildCategory, + }); + return cat.id; + } - if (!category) { - category = await guild.channels.create({ - name: this.categoryName, - type: ChannelType.GuildCategory, + private async ensureCategoryId(): Promise { + if (!this.categoryIdPromise) { + this.categoryIdPromise = this._resolveCategory().catch((err) => { + this.categoryIdPromise = undefined; + throw err; }); } + return this.categoryIdPromise; + } - const channelOpts: GuildChannelCreateOptions = { - name, - type: ChannelType.GuildText, - parent: category.id, - }; + async createTopic(name: string): Promise { + const guild = await this.client.guilds.fetch(this.guildId); + const categoryId = await this.ensureCategoryId(); - const channel = await guild.channels.create(channelOpts); - return channel.id; + try { + const channel = await guild.channels.create({ + name, + type: ChannelType.GuildText, + parent: categoryId, + }); + return channel.id; + } catch (err: unknown) { + // 10003 = Unknown Channel — category was deleted externally + if ((err as { code?: number }).code === 10003) { + this.categoryIdPromise = undefined; + const freshId = await this.ensureCategoryId(); + const channel = await guild.channels.create({ + name, + type: ChannelType.GuildText, + parent: freshId, + }); + return channel.id; + } + throw err; + } } async deleteTopic(topicId: number | string): Promise { @@ -469,8 +643,8 @@ export class DiscordAdapter extends EventEmitter implements ChannelAdapter { return code; } - async confirmPairing(code: string): Promise { - return this.accessManager.confirmCode(code); + async confirmPairing(code: string, callerUserId?: string): Promise { + return this.accessManager.confirmCode(code, callerUserId); } } diff --git a/scripts/publish.sh b/scripts/publish.sh new file mode 100755 index 00000000..6cdaea66 --- /dev/null +++ b/scripts/publish.sh @@ -0,0 +1,146 @@ +#!/usr/bin/env bash +# AgEnD Publish Script +# Usage: ./scripts/publish.sh [patch|minor|major] +# Requires: NPM_TOKEN env var (never stored in repo) +# +# Publishes both @songsid/agend and @songsid/agend-plugin-discord +# Automatically: bumps version, builds, swaps package name, publishes, reverts + +set -euo pipefail + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +NC='\033[0m' + +info() { echo -e "${GREEN}✓${NC} $1"; } +warn() { echo -e "${YELLOW}!${NC} $1"; } +error() { echo -e "${RED}✗${NC} $1"; exit 1; } + +# ── Pre-checks ────────────────────────────────────────────── + +BUMP="${1:-patch}" +if [[ "$BUMP" != "patch" && "$BUMP" != "minor" && "$BUMP" != "major" ]]; then + error "Usage: $0 [patch|minor|major]" +fi + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$REPO_ROOT" + +# Require NPM_TOKEN from environment (never hardcoded) +if [ -z "${NPM_TOKEN:-}" ]; then + error "NPM_TOKEN env var is required. Export it before running:\n export NPM_TOKEN=npm_xxxx" +fi + +# Ensure clean working tree (no uncommitted changes that could leak secrets) +if [ -n "$(git status --porcelain -- ':!.kiro')" ]; then + error "Working tree is dirty. Commit or stash changes first." +fi + +# Verify tsc passes +info "Type checking..." +npx tsc --noEmit || error "TypeScript compilation failed" + +# ── Determine versions ────────────────────────────────────── + +CURRENT_VERSION=$(node -p "require('./package.json').version") +# Calculate next version +IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT_VERSION" +case "$BUMP" in + patch) PATCH=$((PATCH + 1)) ;; + minor) MINOR=$((MINOR + 1)); PATCH=0 ;; + major) MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0 ;; +esac +NEXT_VERSION="$MAJOR.$MINOR.$PATCH" + +echo "" +echo " Publishing @songsid/agend@${NEXT_VERSION} (bump: ${BUMP})" +echo " Publishing @songsid/agend-plugin-discord@${NEXT_VERSION}" +echo "" +read -rp " Confirm? [Y/n] " answer +if [[ "${answer:-Y}" == "n" || "${answer:-Y}" == "N" ]]; then + echo "Aborted." + exit 0 +fi + +# ── Set up temporary .npmrc (avoids global config pollution) ── + +TEMP_NPMRC="$REPO_ROOT/.npmrc" +trap 'rm -f "$TEMP_NPMRC"' EXIT +echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > "$TEMP_NPMRC" + +# Verify token works +NPM_USER=$(npm whoami --userconfig "$TEMP_NPMRC" 2>/dev/null) || error "npm auth failed. Check NPM_TOKEN." +info "Authenticated as: $NPM_USER" + +# ── Publish main package ──────────────────────────────────── + +info "Building main package..." +npm run build + +# Swap name + version for publish +python3 -c " +import json +with open('package.json', 'r') as f: + pkg = json.load(f) +pkg['name'] = '@songsid/agend' +pkg['version'] = '$NEXT_VERSION' +with open('package.json', 'w') as f: + json.dump(pkg, f, indent=2) + f.write('\n') +" + +# Verify no secrets in package +if npm pack --dry-run 2>&1 | grep -qiE "\.env|\.npmrc|token|secret|credential"; then + # Revert before erroring + git checkout -- package.json + error "Potential secret detected in package contents!" +fi + +npm publish --access public --userconfig "$TEMP_NPMRC" +info "@songsid/agend@${NEXT_VERSION} published" + +# Revert main package.json +git checkout -- package.json + +# ── Publish Discord plugin ────────────────────────────────── + +PLUGIN_DIR="$REPO_ROOT/plugins/agend-plugin-discord" +cd "$PLUGIN_DIR" + +info "Building Discord plugin..." +npm run build + +# Swap imports in dist from @suzuke to @songsid +sed -i 's|@suzuke/agend|@songsid/agend|g' dist/*.js dist/*.d.ts 2>/dev/null || true + +# Swap package.json for publish +python3 -c " +import json +with open('package.json', 'r') as f: + pkg = json.load(f) +pkg['name'] = '@songsid/agend-plugin-discord' +pkg['version'] = '$NEXT_VERSION' +pkg['peerDependencies'] = {'@songsid/agend': '>=${NEXT_VERSION}'} +with open('package.json', 'w') as f: + json.dump(pkg, f, indent=2) + f.write('\n') +" + +npm publish --access public --userconfig "$TEMP_NPMRC" +info "@songsid/agend-plugin-discord@${NEXT_VERSION} published" + +# Revert plugin package.json +git checkout -- package.json + +cd "$REPO_ROOT" + +# ── Done ──────────────────────────────────────────────────── + +echo "" +info "Published successfully:" +echo " @songsid/agend@${NEXT_VERSION}" +echo " @songsid/agend-plugin-discord@${NEXT_VERSION}" +echo "" +echo " Users update with:" +echo " npm install -g @songsid/agend@latest @songsid/agend-plugin-discord@latest" diff --git a/src/access-path.ts b/src/access-path.ts index 3bb51138..723c830b 100644 --- a/src/access-path.ts +++ b/src/access-path.ts @@ -1,8 +1,29 @@ import { join } from "node:path"; +/** + * Conservative whitelist for an instance name when it is going to be used as a + * path segment. Allows letters, digits, `_`, `-`, `.`. Empty / pure-`.` / + * pure-`..` is rejected so we cannot escape `dataDir/instances/`. + * + * Defence-in-depth: callers (CLI / fleet config loader) already constrain + * instance names, but `resolveAccessPathFromConfig` is invoked from several + * entry points and the consequence of a traversal here is reading or writing + * an attacker-supplied file path. + */ +const VALID_INSTANCE_NAME = /^[A-Za-z0-9._-]+$/; + +function assertSafeInstanceName(instance: string): void { + if (!VALID_INSTANCE_NAME.test(instance) || instance === "." || instance === "..") { + throw new Error(`Invalid instance name "${instance}" — must match ${VALID_INSTANCE_NAME}`); + } +} + /** * Resolve the access.json path for an instance. * Topic mode uses fleet-level access; otherwise per-instance. + * + * Throws if `instance` is not a safe path segment (per-instance mode only; + * topic mode does not embed `instance` in the returned path). */ export function resolveAccessPathFromConfig( dataDir: string, @@ -12,5 +33,6 @@ export function resolveAccessPathFromConfig( if (fleetChannel?.mode === "topic") { return join(dataDir, "access", "access.json"); } + assertSafeInstanceName(instance); return join(dataDir, "instances", instance, "access.json"); } diff --git a/src/adapter-world.ts b/src/adapter-world.ts new file mode 100644 index 00000000..30cc39c6 --- /dev/null +++ b/src/adapter-world.ts @@ -0,0 +1,49 @@ +import type { ChannelAdapter, SendOpts, SentMessage } from "./channel/types.js"; +import type { AccessManager } from "./channel/access-manager.js"; +import type { ChannelConfig } from "./types.js"; + +/** + * AdapterWorld encapsulates a single channel adapter with its associated + * access manager and config. Each platform (Telegram, Discord) gets its + * own world. Instances are bound to exactly one world. + */ +export class AdapterWorld { + botUsername?: string; + botUserId?: string; + + constructor( + readonly id: string, + readonly adapter: ChannelAdapter, + readonly accessManager: AccessManager, + readonly channelConfig: ChannelConfig, + ) {} + + get groupId(): string { return String(this.channelConfig.group_id ?? ""); } + get type(): string { return this.channelConfig.type; } + + // ── Thin wrappers over adapter ── + + sendText(chatId: string, text: string, opts?: SendOpts): Promise { + return this.adapter.sendText(chatId, text, opts); + } + + react(chatId: string, messageId: string, emoji: string): Promise { + return this.adapter.react(chatId, messageId, emoji); + } + + editMessage(chatId: string, messageId: string, text: string): Promise { + return this.adapter.editMessage(chatId, messageId, text); + } + + downloadAttachment(fileId: string): Promise { + return this.adapter.downloadAttachment(fileId); + } + + isAllowed(userId: string | number): boolean { + return this.accessManager.isAllowed(userId); + } + + async stop(): Promise { + await this.adapter.stop(); + } +} diff --git a/src/agent-cli.ts b/src/agent-cli.ts index 1195085c..07914d87 100644 --- a/src/agent-cli.ts +++ b/src/agent-cli.ts @@ -4,22 +4,42 @@ * Sends JSON POST to the daemon's /agent endpoint and prints JSON result. * * Usage: agend-agent [args...] - * Env: AGEND_PORT (default 19280), AGEND_INSTANCE_NAME (required) + * Env: AGEND_PORT (default 19280), AGEND_INSTANCE_NAME (required), + * AGEND_HOME (default ~/.agend) */ import { request } from "node:http"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { homedir } from "node:os"; const PORT = parseInt(process.env.AGEND_PORT ?? "19280", 10); const INSTANCE = process.env.AGEND_INSTANCE_NAME ?? ""; +const DATA_DIR = process.env.AGEND_HOME || join(homedir(), ".agend"); + +function readInstanceToken(): string | null { + if (!INSTANCE) return null; + try { + return readFileSync(join(DATA_DIR, "instances", INSTANCE, "agent.token"), "utf-8").trim(); + } catch { + return null; + } +} function post(op: string, args: Record): Promise { return new Promise((resolve, reject) => { const body = JSON.stringify({ instance: INSTANCE, op, args }); + const token = readInstanceToken(); + const headers: Record = { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(body), + }; + if (token) headers["X-Agend-Instance-Token"] = token; const req = request({ hostname: "127.0.0.1", port: PORT, path: "/agent", method: "POST", - headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) }, + headers, }, (res) => { let data = ""; res.on("data", (chunk: Buffer) => { data += chunk; }); diff --git a/src/agent-endpoint.ts b/src/agent-endpoint.ts index 007d0363..8831b950 100644 --- a/src/agent-endpoint.ts +++ b/src/agent-endpoint.ts @@ -4,8 +4,17 @@ * * Request: POST /agent { "instance": "dev", "op": "reply", "args": { "text": "hello" } } * Response: JSON result (same shape as MCP tool results) + * + * Authentication: every request must carry `X-Agend-Instance-Token`. The + * daemon writes a fresh 32-byte token to /agent.token (mode 0600) + * on each spawn; agent-cli reads it and sends it in the header. The endpoint + * verifies the header matches the on-disk token for the claimed instance, + * preventing a local process from impersonating another instance. */ import type { IncomingMessage, ServerResponse } from "node:http"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { timingSafeEqual } from "node:crypto"; import type { OutboundContext } from "./outbound-handlers.js"; import { outboundHandlers } from "./outbound-handlers.js"; import { routeToolCall } from "./channel/tool-router.js"; @@ -47,6 +56,8 @@ const OP_MAP: Record = { type CrudHandler = (ctx: OutboundContext, instance: string, args: Record) => Promise; export interface AgentEndpointContext extends OutboundContext { + /** Absolute data directory (e.g. ~/.agend). Used to locate per-instance token files. */ + readonly dataDir: string; handleScheduleCrudHttp(instance: string, op: string, args: Record): Promise; handleDecisionCrudHttp(instance: string, op: string, args: Record): Promise; handleTaskCrudHttp(instance: string, args: Record): Promise; @@ -54,6 +65,37 @@ export interface AgentEndpointContext extends OutboundContext { handleSetDescriptionHttp(instance: string, description: string): Promise; } +/** + * Constant-time comparison of the provided header against the per-instance + * token file. Returns true on match, false on any error (missing file, bad + * instance name, length mismatch, wrong value). + */ +function verifyInstanceToken( + ctx: AgentEndpointContext, + instance: string, + provided: string | undefined, +): boolean { + if (!provided) return false; + // instance name must be a safe filename component + if (!/^[A-Za-z0-9._-]+$/.test(instance)) return false; + const tokenPath = join(ctx.dataDir, "instances", instance, "agent.token"); + let expected: string; + try { + expected = readFileSync(tokenPath, "utf-8").trim(); + } catch { + return false; + } + if (!expected) return false; + const a = Buffer.from(provided); + const b = Buffer.from(expected); + if (a.length !== b.length) return false; + try { + return timingSafeEqual(a, b); + } catch { + return false; + } +} + export function handleAgentRequest( req: IncomingMessage, res: ServerResponse, @@ -81,6 +123,16 @@ export function handleAgentRequest( return; } + const headerToken = req.headers["x-agend-instance-token"]; + const providedToken = typeof headerToken === "string" + ? headerToken + : Array.isArray(headerToken) ? headerToken[0] : undefined; + if (!verifyInstanceToken(ctx, instance, providedToken)) { + res.writeHead(403); + res.end(JSON.stringify({ error: "Invalid or missing instance token" })); + return; + } + const result = await dispatch(ctx, instance, op, args); res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify(result)); @@ -135,11 +187,12 @@ async function dispatch( const threadId = ctx.fleetConfig?.instances[instance]?.topic_id != null ? String(ctx.fleetConfig.instances[instance].topic_id) : undefined; - const chatId = ctx.fleetConfig?.channel?.group_id + const chatId = ctx.getGroupIdForInstance?.(instance) ?? (ctx.fleetConfig?.channel?.group_id ? String(ctx.fleetConfig.channel.group_id) - : ""; + : ""); const fullArgs = { ...args, chat_id: chatId, thread_id: threadId }; - const handled = routeToolCall(ctx.adapter!, tool, fullArgs, threadId, (result, error) => { + const adapter = ctx.getAdapterForInstance?.(instance) ?? ctx.adapter!; + const handled = routeToolCall(adapter, tool, fullArgs, threadId, (result, error) => { resolve(error ? { error } : result); }); if (!handled) resolve({ error: `Unhandled channel tool: ${tool}` }); diff --git a/src/backend/antigravity.ts b/src/backend/antigravity.ts new file mode 100644 index 00000000..2c1d0a97 --- /dev/null +++ b/src/backend/antigravity.ts @@ -0,0 +1,91 @@ +import { join } from "node:path"; +import { homedir } from "node:os"; +import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs"; +import { type CliBackend, type CliBackendConfig, type ErrorPattern, type StartupDialog, isModelCompatible, resolveBinary } from "./types.js"; +import { appendWithMarker, removeMarker } from "./marker-utils.js"; + +export class AntigravityBackend implements CliBackend { + readonly binaryName = "agy"; + private binaryPath: string; + + constructor(private instanceDir: string) { + this.binaryPath = resolveBinary("agy"); + } + + buildCommand(config: CliBackendConfig): string { + let cmd = `${this.binaryPath} --dangerously-skip-permissions`; + if (!config.skipResume) cmd += " --continue"; + if (config.model && isModelCompatible("antigravity", config.model)) cmd += ` --model ${config.model.replace(/[^a-zA-Z0-9_./ ()-]/g, "")}`; + return cmd; + } + + /** + * If workingDirectory is under a hidden path (e.g. ~/.agend/workspaces/), + * agy refuses to operate. Use a real non-hidden directory instead. + */ + resolveWorkingDirectory(workingDirectory: string, instanceName?: string): string { + const home = homedir(); + const rel = workingDirectory.startsWith(home) ? "~" + workingDirectory.slice(home.length) : workingDirectory; + const parts = rel.split("/"); + const hasHidden = parts.some(p => p.startsWith(".") && p !== "~"); + if (!hasHidden) return workingDirectory; + + const name = instanceName || parts[parts.length - 1] || "workspace"; + const resolvedDir = join(home, "agend-workspaces", name); + mkdirSync(resolvedDir, { recursive: true }); + return resolvedDir; + } + + writeConfig(config: CliBackendConfig): void { + // Write .agents/agents.md in the resolved CWD (which may differ from config.workingDirectory) + const cwd = this.resolveWorkingDirectory(config.workingDirectory, config.instanceName); + const agentsDir = join(cwd, ".agents"); + mkdirSync(agentsDir, { recursive: true }); + + if (config.instructions) { + const agentsPath = join(agentsDir, "agents.md"); + appendWithMarker(agentsPath, config.instanceName, config.instructions); + } + } + + cleanup(config: CliBackendConfig): void { + const cwd = join(homedir(), "agend-workspaces", config.instanceName); + if (existsSync(cwd)) { + try { rmSync(cwd, { recursive: true }); } catch { /* ignore */ } + } + } + + getReadyPattern(): RegExp { + return /\? for shortcuts|Gemini/m; + } + + getContextUsage(): number | null { + return null; + } + + getSessionId(): string | null { + try { + const f = join(this.instanceDir, "session-id"); + return readFileSync(f, "utf-8").trim() || null; + } catch { return null; } + } + + getQuitCommand(): string { return "/quit"; } + + getErrorPatterns(): ErrorPattern[] { + return [ + { pattern: /RESOURCE_EXHAUSTED|quota/i, type: "quota", action: "notify", message: "Quota exhausted" }, + { pattern: /error.*authentication|UNAUTHENTICATED/i, type: "auth_error", action: "restart", message: "Authentication error" }, + ]; + } + + getStartupDialogs(): StartupDialog[] { + return [ + { pattern: /Do you trust.*folder|Yes, I trust/i, keys: ["Enter"], description: "Trust folder prompt" }, + ]; + } + + getRuntimeDialogs(): StartupDialog[] { + return []; + } +} diff --git a/src/backend/claude-code.ts b/src/backend/claude-code.ts index 14ca67b5..ca881c58 100644 --- a/src/backend/claude-code.ts +++ b/src/backend/claude-code.ts @@ -1,7 +1,7 @@ import { join } from "node:path"; import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; -import { type CliBackend, type CliBackendConfig, type ErrorPattern, type RuntimeDialog, type StartupDialog, resolveBinary, shellQuote, validateModel } from "./types.js"; +import { type CliBackend, type CliBackendConfig, type ErrorPattern, type RuntimeDialog, type StartupDialog, isModelCompatible, resolveBinary, shellQuote, validateModel } from "./types.js"; export class ClaudeCodeBackend implements CliBackend { @@ -30,7 +30,7 @@ export class ClaudeCodeBackend implements CliBackend { if (sid && /^[a-zA-Z0-9_-]+$/.test(sid)) cmd += ` --resume ${sid}`; } - if (config.model) { + if (config.model && isModelCompatible("claude-code", config.model)) { cmd += ` --model ${validateModel(config.model)}`; } diff --git a/src/backend/codex.ts b/src/backend/codex.ts index 3593f54f..8b7d56c4 100644 --- a/src/backend/codex.ts +++ b/src/backend/codex.ts @@ -2,7 +2,7 @@ import { execFileSync } from "node:child_process"; import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, join } from "node:path"; -import { type CliBackend, type CliBackendConfig, type ErrorPattern, type RuntimeDialog, type StartupDialog, resolveBinary, validateModel } from "./types.js"; +import { type CliBackend, type CliBackendConfig, type ErrorPattern, type RuntimeDialog, type StartupDialog, isModelCompatible, resolveBinary, validateModel } from "./types.js"; import { appendWithMarker, removeMarker } from "./marker-utils.js"; const CODEX_PROJECT_DOC_MAX_BYTES = 32_768; @@ -30,7 +30,7 @@ export class CodexBackend implements CliBackend { } else { cmd = `${this.binaryPath} resume --last ${approvalFlag}`; } - if (config.model) cmd += ` -c model="${validateModel(config.model)}"`; + if (config.model && isModelCompatible("codex", config.model)) cmd += ` -c model="${validateModel(config.model)}"`; return cmd; } diff --git a/src/backend/factory.ts b/src/backend/factory.ts index 309d20d2..9d07d311 100644 --- a/src/backend/factory.ts +++ b/src/backend/factory.ts @@ -4,6 +4,7 @@ import { GeminiCliBackend } from "./gemini-cli.js"; import { CodexBackend } from "./codex.js"; import { OpenCodeBackend } from "./opencode.js"; import { KiroBackend } from "./kiro.js"; +import { AntigravityBackend } from "./antigravity.js"; import { MockBackend } from "./mock.js"; export function createBackend(name: string, instanceDir: string): CliBackend { @@ -11,6 +12,7 @@ export function createBackend(name: string, instanceDir: string): CliBackend { case "claude-code": return new ClaudeCodeBackend(instanceDir); case "gemini-cli": + console.warn("⚠️ gemini-cli is deprecated (stops 2026-06-18). Consider switching to backend: antigravity"); return new GeminiCliBackend(instanceDir); case "codex": return new CodexBackend(instanceDir); @@ -18,9 +20,11 @@ export function createBackend(name: string, instanceDir: string): CliBackend { return new OpenCodeBackend(instanceDir); case "kiro-cli": return new KiroBackend(instanceDir); + case "antigravity": + return new AntigravityBackend(instanceDir); case "mock": return new MockBackend(instanceDir); default: - throw new Error(`Unknown backend: ${name}. Available: claude-code, gemini-cli, codex, opencode, kiro-cli, mock`); + throw new Error(`Unknown backend: ${name}. Available: claude-code, gemini-cli, codex, opencode, kiro-cli, antigravity, mock`); } } diff --git a/src/backend/kiro.ts b/src/backend/kiro.ts index 8c2c7348..ca83f6c1 100644 --- a/src/backend/kiro.ts +++ b/src/backend/kiro.ts @@ -1,6 +1,6 @@ import { join } from "node:path"; import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync, unlinkSync } from "node:fs"; -import { type CliBackend, type CliBackendConfig, type ErrorPattern, type StartupDialog, type RuntimeDialog, resolveBinary, validateModel } from "./types.js"; +import { type CliBackend, type CliBackendConfig, type ErrorPattern, type StartupDialog, type RuntimeDialog, isModelCompatible, resolveBinary, validateModel } from "./types.js"; export class KiroBackend implements CliBackend { readonly binaryName = "kiro-cli"; @@ -11,11 +11,11 @@ export class KiroBackend implements CliBackend { } buildCommand(config: CliBackendConfig): string { - let cmd = `${this.binaryPath} chat`; + let cmd = `${this.binaryPath} chat --legacy-ui`; if (config.skipPermissions !== false) cmd += " --trust-all-tools"; // --resume is boolean: Kiro auto-resumes latest conversation for this working directory if (!config.skipResume) cmd += " --resume"; - if (config.model) cmd += ` --model ${validateModel(config.model)}`; + if (config.model && isModelCompatible("kiro-cli", config.model)) cmd += ` --model ${validateModel(config.model)}`; cmd += " --require-mcp-startup"; return cmd; } @@ -95,9 +95,7 @@ export class KiroBackend implements CliBackend { getErrorPatterns(): ErrorPattern[] { return [ - { pattern: /rate.?limit|429|too many requests/i, type: "rate_limit", action: "failover", message: "Rate limit reached" }, - { pattern: /auth.*error|unauthorized|401/i, type: "auth_error", action: "pause", message: "Authentication error" }, - { pattern: /usage limit|insufficient.?credit|credit.*exhaust/i, type: "quota", action: "pause", message: "Usage limit reached" }, + { pattern: /having trouble responding/i, type: "rate_limit", action: "notify", message: "Rate limit (having trouble responding)" }, ]; } diff --git a/src/backend/opencode.ts b/src/backend/opencode.ts index c044b31c..a0a0599f 100644 --- a/src/backend/opencode.ts +++ b/src/backend/opencode.ts @@ -1,6 +1,6 @@ import { join } from "node:path"; import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; -import { type CliBackend, type CliBackendConfig, type ErrorPattern, type StartupDialog, resolveBinary, validateModel } from "./types.js"; +import { type CliBackend, type CliBackendConfig, type ErrorPattern, type StartupDialog, type RuntimeDialog, isModelCompatible, resolveBinary, validateModel } from "./types.js"; export class OpenCodeBackend implements CliBackend { readonly binaryName = "opencode"; @@ -45,7 +45,11 @@ export class OpenCodeBackend implements CliBackend { // MCP servers — use instance name as key to avoid multi-instance conflicts const mcp = (oc.mcp ?? {}) as Record; for (const [name, entry] of Object.entries(config.mcpServers)) { - const instanceKey = `${name}-${config.instanceName}`; + const safeInstanceName = config.instanceName.replace(/[^\x20-\x7E]/g, "").replace(/\s+/g, "-") || config.instanceName.replace(/[^a-zA-Z0-9-]/g, "x"); + const instanceKey = `${name}-${safeInstanceName}`; + // Remove old non-sanitized key if present + const oldKey = `${name}-${config.instanceName}`; + if (oldKey !== instanceKey) delete mcp[oldKey]; mcp[instanceKey] = { type: "local", command: [entry.command, ...entry.args], @@ -95,6 +99,13 @@ export class OpenCodeBackend implements CliBackend { getQuitCommand(): string { return "/quit"; } + getRuntimeDialogs(): RuntimeDialog[] { + return [ + { pattern: /Permission required/i, keys: ["Right", "Enter"], description: "OpenCode permission prompt — Allow always" }, + { pattern: /confirm/i, keys: ["Enter"], description: "OpenCode confirm prompt" }, + ]; + } + cleanup(config: CliBackendConfig): void { // Clean up instance-specific MCP entries from opencode.json. // Only remove namespaced keys — non-namespaced "agend" key may belong to @@ -105,7 +116,9 @@ export class OpenCodeBackend implements CliBackend { const oc = JSON.parse(readFileSync(configPath, "utf-8")); if (oc.mcp) { for (const name of Object.keys(config.mcpServers)) { - delete oc.mcp[`${name}-${config.instanceName}`]; + const safeName = config.instanceName.replace(/[^\x20-\x7E]/g, "").replace(/\s+/g, "-") || config.instanceName.replace(/[^a-zA-Z0-9-]/g, "x"); + delete oc.mcp[`${name}-${safeName}`]; + delete oc.mcp[`${name}-${config.instanceName}`]; // clean up old non-sanitized keys } } // Remove fleet instructions path from instructions diff --git a/src/backend/types.ts b/src/backend/types.ts index 44878bdc..94bfada1 100644 --- a/src/backend/types.ts +++ b/src/backend/types.ts @@ -90,6 +90,9 @@ export interface CliBackend { /** Pre-approve a working directory to skip trust dialogs on startup. */ preTrust?(workingDirectory: string): void; + /** Resolve working directory (e.g. create symlink to avoid hidden paths). Returns resolved path. */ + resolveWorkingDirectory?(workingDirectory: string, instanceName?: string): string; + /** Command to gracefully quit the CLI (e.g. "/exit", "/quit"). */ getQuitCommand(): string; @@ -125,6 +128,23 @@ export function validateModel(model: string): string { return model; } +/** Known model prefixes/patterns per backend. Model is skipped if it doesn't match the target backend. */ +const BACKEND_MODEL_PATTERNS: Record = { + "claude-code": /^(sonnet|opus|haiku|opusplan|best|claude)/i, + "kiro-cli": /^(claude|sonnet|opus|haiku)/i, + "codex": /^(gpt|o[0-9]|chatgpt)/i, + "gemini-cli": /^gemini/i, + "opencode": /./, // opencode accepts anything (provider-dependent) + "antigravity": / /, // agy models always contain spaces (display names like "Gemini 3.5 Flash (High)") +}; + +/** Check if a model name is compatible with the given backend. */ +export function isModelCompatible(backendName: string, model: string): boolean { + const pattern = BACKEND_MODEL_PATTERNS[backendName]; + if (!pattern) return true; // unknown backend — pass through + return pattern.test(model); +} + /** POSIX single-quote escape for embedding arbitrary values in a shell command. */ export function shellQuote(s: string): string { return `'${s.replace(/'/g, "'\\''")}'`; diff --git a/src/channel/access-manager.ts b/src/channel/access-manager.ts index afface76..0261433e 100644 --- a/src/channel/access-manager.ts +++ b/src/channel/access-manager.ts @@ -10,7 +10,7 @@ interface PendingCode { } interface AccessState { - mode?: "pairing" | "locked"; + mode?: "pairing" | "locked" | "open"; allowed_users: (number | string)[]; pending_codes: PendingCode[]; } @@ -72,6 +72,7 @@ export class AccessManager { } isAllowed(userId: number | string): boolean { + if (this.getMode() === "open") return true; const key = String(userId); return this.state.allowed_users.some(u => String(u) === key); } @@ -169,12 +170,12 @@ export class AccessManager { return true; } - setMode(mode: "pairing" | "locked"): void { + setMode(mode: "pairing" | "locked" | "open"): void { this.state.mode = mode; this.persist(); } - getMode(): "pairing" | "locked" { + getMode(): "pairing" | "locked" | "open" { return this.state.mode ?? this.config.mode; } diff --git a/src/channel/adapters/telegram.ts b/src/channel/adapters/telegram.ts index f4fc7ab7..fdd2924c 100644 --- a/src/channel/adapters/telegram.ts +++ b/src/channel/adapters/telegram.ts @@ -19,6 +19,18 @@ function toThreadId(threadId: string | null | undefined): number | undefined { return Number(threadId); } +/** Recursively extract plain text from RichText nodes (Bot API 10.1) */ +function extractRichText(nodes: unknown): string { + if (typeof nodes === "string") return nodes; + if (Array.isArray(nodes)) return nodes.map(extractRichText).join(""); + if (nodes && typeof nodes === "object") { + const n = nodes as Record; + if (n.text != null) return extractRichText(n.text); + if (n.blocks != null) return extractRichText(n.blocks); + } + return ""; +} + export interface TelegramAdapterOptions { id: string; botToken: string; @@ -28,6 +40,44 @@ export interface TelegramAdapterOptions { apiRoot?: string; } +/** + * Whitelist of legitimate Telegram API roots. Misconfiguring `apiRoot` + * (or having it set by an attacker via a config file write) would leak + * the bot token — every API call sends the token in the path. Only the + * official endpoint and loopback (E2E mock servers) are allowed. + */ +const TELEGRAM_API_HOST_ALLOWLIST = new Set([ + "api.telegram.org", + "localhost", + "127.0.0.1", + "::1", +]); + +export function validateTelegramApiRoot(apiRoot: string): void { + let url: URL; + try { + url = new URL(apiRoot); + } catch { + throw new Error(`Invalid telegram_api_root URL: ${apiRoot}`); + } + if (url.protocol !== "https:" && url.protocol !== "http:") { + throw new Error(`telegram_api_root must use http(s): ${apiRoot}`); + } + // http:// only allowed for loopback (mock servers); production must be https. + // URL.hostname wraps IPv6 in brackets ("[::1]") — strip them for allowlist match. + const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, ""); + if (!TELEGRAM_API_HOST_ALLOWLIST.has(host)) { + throw new Error( + `telegram_api_root host "${host}" is not in the allowlist ` + + `(${[...TELEGRAM_API_HOST_ALLOWLIST].join(", ")}). ` + + `Sending the bot token to an arbitrary host would leak credentials.`, + ); + } + if (url.protocol === "http:" && host === "api.telegram.org") { + throw new Error("telegram_api_root must use https for api.telegram.org"); + } +} + export class TelegramAdapter extends EventEmitter implements ChannelAdapter { readonly type = "telegram"; readonly topology = "topics" as const; @@ -45,6 +95,7 @@ export class TelegramAdapter extends EventEmitter implements ChannelAdapter { this.id = opts.id; this.accessManager = opts.accessManager; this.inboxDir = opts.inboxDir; + if (opts.apiRoot) validateTelegramApiRoot(opts.apiRoot); this.apiRoot = (opts.apiRoot ?? "https://api.telegram.org").replace(/\/+$/, ""); mkdirSync(this.inboxDir, { recursive: true }); @@ -99,8 +150,15 @@ export class TelegramAdapter extends EventEmitter implements ChannelAdapter { // In pairing mode, allow /pair commands through if (msg.text?.startsWith("/pair")) { await this._handlePairCommand(ctx); + return; } - return; + // Allow messages from non-primary chats through for classic mode + // (classic mode has its own access control in fleet-manager) + const chatId = String(msg.chat.id); + if (this.lastChatId && chatId === this.lastChatId) { + return; + } + // Non-primary chat: let it through to fleet-manager for classic routing } // Skip service messages (topic rename, pin, member join/leave, etc.) @@ -112,7 +170,10 @@ export class TelegramAdapter extends EventEmitter implements ChannelAdapter { : undefined; const messageId = String(msg.message_id); const username = msg.from?.username ?? msg.from?.first_name ?? String(userId); - const text = msg.text ?? msg.caption ?? ""; + let text = msg.text ?? msg.caption ?? ""; + if (!text && (msg as any).rich_message) { + text = extractRichText((msg as any).rich_message.blocks); + } // Collect attachments const attachments = this._extractAttachments(msg); @@ -129,11 +190,13 @@ export class TelegramAdapter extends EventEmitter implements ChannelAdapter { username, text, timestamp: new Date(msg.date * 1000), + isBotMessage: msg.from?.is_bot ?? false, attachments: attachments.length > 0 ? attachments : undefined, replyTo: msg.reply_to_message?.message_id != null ? String(msg.reply_to_message.message_id) : undefined, replyToText: replyToText || undefined, + chatTitle: msg.chat.title || undefined, }); }); @@ -261,28 +324,47 @@ export class TelegramAdapter extends EventEmitter implements ChannelAdapter { }); // 409 Conflict = another getUpdates consumer is active (official plugin zombie, - // or second Claude Code instance). Retry with backoff until the slot frees up. - // Pattern from official telegram plugin. + // or second Claude Code instance). Retry with backoff, but cap attempts so a + // permanently-stuck conflict does not loop forever. + const MAX_409_RETRIES = 30; // ~7 min total at 15s ceiling + const MAX_RECONNECT_RETRIES = 10; // auto-reconnect on non-409 errors void (async () => { - for (let attempt = 1; ; attempt++) { - try { - await this.bot.start({ - drop_pending_updates: attempt === 1, - onStart: (info) => { - this.emit("started", info.username); - }, - }); - return; // bot.stop() was called — clean exit - } catch (err) { - if (err instanceof GrammyError && err.error_code === 409) { - const delay = Math.min(1000 * attempt, 15000); - this.emit("polling_conflict", { attempt, delay }); - await new Promise(r => setTimeout(r, delay)); - continue; + let reconnects = 0; + while (true) { + for (let attempt = 1; attempt <= MAX_409_RETRIES; attempt++) { + try { + await this.bot.start({ + drop_pending_updates: attempt === 1 && reconnects === 0, + onStart: (info) => { + reconnects = 0; // reset on successful start + this.emit("started", info.username); + }, + }); + return; // bot.stop() was called — clean exit + } catch (err) { + if (err instanceof GrammyError && err.error_code === 409) { + if (attempt >= MAX_409_RETRIES) { + this.emit("error", new Error( + `Telegram polling: 409 conflict persisted after ${MAX_409_RETRIES} attempts; giving up`, + )); + return; + } + const delay = Math.min(1000 * attempt, 15000); + this.emit("polling_conflict", { attempt, delay }); + await new Promise(r => setTimeout(r, delay)); + continue; + } + if (err instanceof Error && err.message === "Aborted delay") return; + // Auto-reconnect on transient errors (network, timeout, 5xx) + reconnects++; + if (reconnects > MAX_RECONNECT_RETRIES) { + this.emit("error", new Error(`Telegram polling: gave up after ${reconnects} reconnect attempts. Last error: ${(err as Error).message}`)); + return; + } + console.warn(`[telegram] Polling error (reconnect ${reconnects}/${MAX_RECONNECT_RETRIES}): ${(err as Error).message}`); + await new Promise(r => setTimeout(r, Math.min(5000 * reconnects, 30000))); + break; // break inner loop to restart bot.start() } - if (err instanceof Error && err.message === "Aborted delay") return; - this.emit("error", err); - return; } } })(); @@ -311,7 +393,23 @@ export class TelegramAdapter extends EventEmitter implements ChannelAdapter { // ── Text / file sending ─────────────────────────────────────────────────── + private needsRichMessage(text: string): boolean { + return /\n\|.+\|.+\|/m.test(text) || /```[\s\S]+?```/.test(text) || /^#{1,6}\s/m.test(text) || /^---$/m.test(text) || /
{ + // Try rich message for content with tables, code blocks, headings, etc. + if (this.needsRichMessage(text)) { + try { + const result = await (this.bot.api as any).raw.sendRichMessage({ + chat_id: Number(chatId), + rich_message: { markdown: text }, + ...(toThreadId(opts?.threadId) ? { message_thread_id: toThreadId(opts?.threadId) } : {}), + }); + return { messageId: String(result.message_id), chatId, threadId: opts?.threadId }; + } catch { /* fallback to normal sendText below */ } + } + return new Promise((resolve, reject) => { // We enqueue and immediately capture the first sent messageId via a one-shot sender // For simplicity we use the bot API directly for the first chunk resolution, @@ -536,7 +634,7 @@ export class TelegramAdapter extends EventEmitter implements ChannelAdapter { const token = (this.bot as unknown as { token: string }).token; const url = `${this.apiRoot}/file/bot${token}/${filePath}`; - const filename = filePath.split("/").pop() ?? fileId; + const filename = `${Date.now()}-${fileId.slice(-8)}-${filePath.split("/").pop() ?? fileId}`; const localPath = join(this.inboxDir, filename); // Download using fetch @@ -667,7 +765,7 @@ export class TelegramAdapter extends EventEmitter implements ChannelAdapter { return code; } - async confirmPairing(code: string): Promise { - return this.accessManager.confirmCode(code); + async confirmPairing(code: string, callerUserId?: string): Promise { + return this.accessManager.confirmCode(code, callerUserId); } } diff --git a/src/channel/attachment-handler.ts b/src/channel/attachment-handler.ts index a2a68e49..8e5c899a 100644 --- a/src/channel/attachment-handler.ts +++ b/src/channel/attachment-handler.ts @@ -23,14 +23,22 @@ export async function processAttachments( const extraMeta: Record = {}; // Auto-download photos so Claude can Read them directly - const photoAttachment = msg.attachments?.find(a => a.kind === "photo"); - if (photoAttachment) { - try { - const localPath = await adapter.downloadAttachment(photoAttachment.fileId); - extraMeta.image_path = localPath; - text = `[📷 Image: ${localPath}]\n${text}`; - } catch (err) { - logger.warn({ err: (err as Error).message }, "Photo download failed"); + const photoAttachments = msg.attachments?.filter(a => a.kind === "photo") ?? []; + if (photoAttachments.length > 0) { + const paths: string[] = []; + for (const photo of photoAttachments) { + try { + const localPath = await adapter.downloadAttachment(photo.fileId); + paths.push(localPath); + } catch (err) { + logger.warn({ err: (err as Error).message }, "Photo download failed"); + } + } + if (paths.length > 0) { + extraMeta.image_path = paths[0]; + if (paths.length > 1) extraMeta.image_paths = paths.join(","); + const tags = paths.map(p => `[📷 Image: ${p}]`).join("\n"); + text = `${tags}\n${text}`; } } @@ -55,9 +63,30 @@ export async function processAttachments( extraMeta.attachment_file_id = voiceAttachment.fileId; } - // Pass other attachment types as file_id for manual download + // Auto-download document attachments so agents can Read them directly + const docAttachments = msg.attachments?.filter(a => a.kind === "document") ?? []; + if (docAttachments.length > 0) { + const paths: string[] = []; + for (const doc of docAttachments) { + try { + const localPath = await adapter.downloadAttachment(doc.fileId); + paths.push(localPath); + const filename = doc.filename ?? "file"; + text = `[📎 File: ${filename} → ${localPath}]\n${text}`; + } catch (err) { + logger.warn({ err: (err as Error).message }, "Document download failed"); + if (!extraMeta.attachment_file_id) extraMeta.attachment_file_id = doc.fileId; + } + } + if (paths.length > 0) { + extraMeta.attachment_path = paths[0]; + if (paths.length > 1) extraMeta.attachment_paths = paths.join(","); + } + } + + // Pass remaining attachment types as file_id for manual download const otherAttachment = msg.attachments?.find(a => - a.kind !== "photo" && a.kind !== "voice" && a.kind !== "audio", + a.kind !== "photo" && a.kind !== "voice" && a.kind !== "audio" && a.kind !== "document", ); if (otherAttachment) { extraMeta.attachment_file_id = otherAttachment.fileId; diff --git a/src/channel/factory.ts b/src/channel/factory.ts index b2ca6217..6e97b752 100644 --- a/src/channel/factory.ts +++ b/src/channel/factory.ts @@ -50,7 +50,8 @@ export async function createAdapter(config: ChannelConfig, opts: AdapterOpts): P // Plugin adapters — try multiple package name conventions const candidates = [ - `@suzuke/agend-plugin-${config.type}`, // scoped official plugin + `@songsid/agend-plugin-${config.type}`, // scoped official plugin + `@suzuke/agend-plugin-${config.type}`, // upstream plugin `agend-plugin-${config.type}`, // community plugin `agend-adapter-${config.type}`, // legacy convention config.type, // bare name @@ -69,6 +70,6 @@ export async function createAdapter(config: ChannelConfig, opts: AdapterOpts): P throw new Error( `Channel adapter "${config.type}" not found. ` + - `Install the plugin: npm install -g @suzuke/agend-plugin-${config.type}` + `Install the plugin: npm install -g @songsid/agend-plugin-${config.type}` ); } diff --git a/src/channel/ipc-bridge.ts b/src/channel/ipc-bridge.ts index af856556..accfdc47 100644 --- a/src/channel/ipc-bridge.ts +++ b/src/channel/ipc-bridge.ts @@ -15,7 +15,11 @@ function encode(msg: unknown): string { return JSON.stringify(msg) + "\n"; } -const MAX_LINE_BUFFER = 10 * 1024 * 1024; // 10 MB +// 1 MB is well above any legitimate IPC payload (tool calls/responses, +// schedule/decision/task lists). The previous 10 MB ceiling was loose +// DoS protection — a runaway producer could buffer 10 MB per client +// before being dropped. +const MAX_LINE_BUFFER = 1 * 1024 * 1024; function makeLineParser(onMessage: (msg: unknown) => void, onOverflow?: () => void) { let buf = ""; diff --git a/src/channel/mcp-tools.ts b/src/channel/mcp-tools.ts index c73d6816..a6b4760b 100644 --- a/src/channel/mcp-tools.ts +++ b/src/channel/mcp-tools.ts @@ -86,6 +86,8 @@ const DEFS: Array<[string, ZodType, string]> = [ "List all currently running instances that you can send messages to."], ["start_instance", schemas.StartInstanceArgs, "Start a stopped instance by name."], + ["restart_instance", schemas.RestartInstanceArgs, + "Restart a running instance. Reloads fleet config and restarts the instance."], ["create_instance", schemas.CreateInstanceArgs, "Create a new instance bound to a project directory with a channel topic. If directory is omitted, a workspace is auto-created at ~/.agend/workspaces/."], ["delete_instance", schemas.DeleteInstanceArgs, @@ -95,7 +97,7 @@ const DEFS: Array<[string, ZodType, string]> = [ ["set_display_name", schemas.SetDisplayNameArgs, "Set your display name. This name will be shown in Telegram messages, activity logs, and when other agents refer to you."], ["set_description", schemas.SetDescriptionArgs, - "Set your role description. This is injected into your system prompt as your role definition. Takes effect on next context rotation."], + "Set your role description. This is injected into your system prompt as your role definition. Takes effect on next session restart."], ["checkout_repo", schemas.CheckoutRepoArgs, "Mount another repo as a read-only worktree. Returns a local path you can Read files from. Use instance name or absolute path as source."], ["release_repo", schemas.ReleaseRepoArgs, diff --git a/src/channel/message-queue.ts b/src/channel/message-queue.ts index ec4588ff..b14b5f50 100644 --- a/src/channel/message-queue.ts +++ b/src/channel/message-queue.ts @@ -122,7 +122,15 @@ export class MessageQueue { const before = state.items.length; state.items = state.items.filter(item => item.type !== "status_update"); if (before !== state.items.length) { - // Items were dropped; reset backoff now that we've cleaned up + // Items were dropped; reset backoff so we retry the surviving + // (presumably more important) content sooner instead of waiting + // out the full exponential delay. + state.backoffMs = INITIAL_BACKOFF_MS; + state.backoffUntil = 0; + this.logger?.warn( + { chatId: state.key.chatId, dropped: before - state.items.length }, + "MessageQueue flood control: dropped status_update items, backoff reset", + ); } } diff --git a/src/channel/types.ts b/src/channel/types.ts index 24a29b2b..2a8e566d 100644 --- a/src/channel/types.ts +++ b/src/channel/types.ts @@ -41,7 +41,7 @@ export interface ChannelAdapter extends EventEmitter { downloadAttachment(fileId: string): Promise; handlePairing(chatId: string, userId: string): Promise; - confirmPairing(code: string): Promise; + confirmPairing(code: string, callerUserId?: string): Promise; readonly topology: "topics" | "channels" | "flat"; @@ -95,6 +95,7 @@ export interface InboundMessage { username: string; text: string; timestamp: Date; + isBotMessage?: boolean; attachments?: Attachment[]; replyTo?: string; replyToText?: string; diff --git a/src/classic-channel-manager.ts b/src/classic-channel-manager.ts new file mode 100644 index 00000000..3e86d94b --- /dev/null +++ b/src/classic-channel-manager.ts @@ -0,0 +1,250 @@ +import { existsSync, readFileSync, writeFileSync, mkdirSync, appendFileSync, readdirSync, unlinkSync, statSync } from "node:fs"; +import { join } from "node:path"; +import yaml from "js-yaml"; +import { getAgendHome } from "./paths.js"; +import { sanitizeInstanceName } from "./topic-commands.js"; +import type { Logger } from "./logger.js"; + +export interface ClassicChannel { + channelId: string; + name: string; + instanceName: string; + backend?: string; + model?: string; + collab?: boolean; + preTaskCommand?: string; + contextLines?: number; + createdAt: string; + createdBy: string; +} + +interface ClassicBotYaml { + defaults?: { backend?: string; model?: string; context_lines?: number; allowed_guilds?: string[]; admin_users?: string[]; allowed_groups?: string[]; allowed_users?: string[] }; + channels?: Record; +} + +const YAML_HEADER = `# ClassicBot Configuration +# Available backends: claude-code, gemini-cli, codex, opencode, kiro-cli +`; + +/** Derive instance name from channel name + last 4 digits of channelId */ +export function classicInstanceName(sanitizedName: string, channelId: string): string { + const suffix = channelId.slice(-4); + return `classic-${sanitizedName}-${suffix}`; +} + +/** + * Manages classic bot channel lifecycle — register/unregister/persist. + * Persists to ~/.agend/classicBot.yaml with per-channel backend override. + * YAML keys are channelId to avoid duplicate name collisions. + */ +export class ClassicChannelManager { + private channels = new Map(); + private defaults: { backend?: string; model?: string; context_lines?: number; allowed_guilds?: string[]; admin_users?: string[]; allowed_groups?: string[]; allowed_users?: string[] } = {}; + private readonly configPath: string; + private lastMtime = 0; + + constructor(private dataDir: string, private logger: Logger) { + this.configPath = join(dataDir, "classicBot.yaml"); + this.load(); + } + + private load(): void { + if (!existsSync(this.configPath)) return; + try { + const raw = yaml.load(readFileSync(this.configPath, "utf-8")) as ClassicBotYaml | null; + if (!raw) return; + this.defaults = raw.defaults ?? {}; + this.channels.clear(); + if (raw.channels) { + for (const [channelId, val] of Object.entries(raw.channels)) { + const name = val.name ?? channelId; + this.channels.set(channelId, { + channelId, + name, + instanceName: classicInstanceName(sanitizeInstanceName(name), channelId), + backend: val.backend, + model: val.model, + collab: val.collab, + preTaskCommand: val.pre_task_command, + contextLines: val.context_lines, + createdAt: val.createdAt ?? "", + createdBy: val.createdBy ?? "", + }); + } + } + this.lastMtime = statSync(this.configPath).mtimeMs; + this.logger.info({ count: this.channels.size }, "Loaded classic channels"); + } catch (err) { + this.logger.warn({ err }, "Failed to load classicBot.yaml"); + } + } + + private save(): void { + mkdirSync(this.dataDir, { recursive: true }); + const obj: ClassicBotYaml = { defaults: this.defaults, channels: {} }; + for (const ch of this.channels.values()) { + const entry: Record = { name: ch.name, createdBy: ch.createdBy, createdAt: ch.createdAt }; + if (ch.backend) entry.backend = ch.backend; + if (ch.model) entry.model = ch.model; + if (ch.contextLines) entry.context_lines = ch.contextLines; + if (ch.collab) entry.collab = ch.collab; + if (ch.preTaskCommand) entry.pre_task_command = ch.preTaskCommand; + obj.channels![ch.channelId] = entry as any; + } + writeFileSync(this.configPath, YAML_HEADER + yaml.dump(obj, { lineWidth: -1 })); + this.lastMtime = existsSync(this.configPath) ? statSync(this.configPath).mtimeMs : 0; + } + + /** Poll for external file changes (call periodically, e.g. every 30s) */ + checkReload(): boolean { + if (!existsSync(this.configPath)) return false; + const mtime = statSync(this.configPath).mtimeMs; + if (mtime <= this.lastMtime) return false; + this.logger.info("classicBot.yaml changed — reloading"); + this.load(); + return true; + } + + getDefaults(): { backend?: string } { return this.defaults; } + + /** Check if a guild is allowed. Empty/unset/non-array allowed_guilds = allow all (backward compat). */ + isGuildAllowed(guildId: string): boolean { + const list = this.defaults.allowed_guilds; + if (!Array.isArray(list) || list.length === 0) return true; + return list.includes(guildId); + } + + /** Check if a Telegram group is allowed. Empty/unset/non-array = allow all. */ + isGroupAllowed(groupId: string): boolean { + const list = this.defaults.allowed_groups; + if (!Array.isArray(list) || list.length === 0) return true; + return list.includes(groupId); + } + + /** Check if a Telegram user (private chat) is allowed. Empty/unset/non-array = allow all. */ + isUserAllowed(userId: string): boolean { + const list = this.defaults.allowed_users; + if (!Array.isArray(list) || list.length === 0) return true; + return list.includes(userId); + } + + /** Check if a user is admin. Empty/unset admin_users = no admins (secure default). */ + isAdmin(userId: string): boolean { + const list = this.defaults.admin_users; + return !!list && list.length > 0 && list.includes(userId); + } + + /** Toggle collab mode for a channel. Returns new state. */ + toggleCollab(channelId: string): boolean { + const ch = this.channels.get(channelId); + if (!ch) return false; + ch.collab = !ch.collab; + this.save(); + return ch.collab; + } + + isCollab(channelId: string): boolean { + return this.channels.get(channelId)?.collab ?? false; + } + + getPreTaskCommand(channelId: string): string | undefined { + return this.channels.get(channelId)?.preTaskCommand; + } + + /** Context lines fallback: per-channel → defaults → 5 */ + getContextLines(channelId: string): number { + const ch = this.channels.get(channelId); + if (ch?.contextLines != null) return ch.contextLines; + if (this.defaults.context_lines != null) return this.defaults.context_lines; + return 5; + } + + /** Backend fallback: per-channel → classic defaults → fleetDefault → "claude-code" */ + getBackend(channelId: string, fleetDefault?: string): string { + const ch = this.channels.get(channelId); + return ch?.backend || this.defaults.backend || fleetDefault || "claude-code"; + } + + /** Get model for a channel — channel override → defaults → fleet default */ + getModel(channelId: string, fleetDefault?: string): string | undefined { + const ch = this.channels.get(channelId); + return ch?.model || this.defaults.model || fleetDefault; + } + + /** Get backend for an instance by name */ + getBackendByInstance(instanceName: string, fleetDefault?: string): string { + for (const ch of this.channels.values()) { + if (ch.instanceName === instanceName) return ch.backend || this.defaults.backend || fleetDefault || "claude-code"; + } + return this.defaults.backend || fleetDefault || "claude-code"; + } + + getChannelIdByInstance(instanceName: string): string | undefined { + for (const ch of this.channels.values()) { + if (ch.instanceName === instanceName) return ch.channelId; + } + return undefined; + } + + isClassicChannel(channelId: string): boolean { return this.channels.has(channelId); } + get(channelId: string): ClassicChannel | undefined { return this.channels.get(channelId); } + getAll(): ClassicChannel[] { return [...this.channels.values()]; } + + register(channelId: string, instanceName: string, channelName: string, userId: string): ClassicChannel { + const ch: ClassicChannel = { channelId, name: channelName, instanceName, createdAt: new Date().toISOString(), createdBy: userId }; + this.channels.set(channelId, ch); + this.save(); + this.logger.info({ channelId, instanceName }, "Registered classic channel"); + return ch; + } + + unregister(channelId: string): ClassicChannel | undefined { + const ch = this.channels.get(channelId); + if (!ch) return undefined; + this.channels.delete(channelId); + this.save(); + this.logger.info({ channelId, instanceName: ch.instanceName }, "Unregistered classic channel"); + return ch; + } + + static chatLogDir(instanceName: string): string { + return join(getAgendHome(), "workspaces", instanceName, "chat-logs"); + } + + static logMessage(instanceName: string, username: string, text: string, timestamp: Date, replyToText?: string): void { + const logDir = ClassicChannelManager.chatLogDir(instanceName); + mkdirSync(logDir, { recursive: true }); + const dateStr = timestamp.toISOString().slice(0, 10); + const logFile = join(logDir, `${dateStr}.log`); + const replyPrefix = replyToText ? `[reply: ${replyToText.slice(0, 100)}] ` : ""; + appendFileSync(logFile, `[${timestamp.toISOString()}] <${username}> ${replyPrefix}${text}\n`); + } + + /** Delete chat log files older than retentionDays. Dates parsed as local to avoid UTC off-by-one. */ + rotateLogs(retentionDays = 7): number { + let deleted = 0; + const cutoff = Date.now() - retentionDays * 86400_000; + for (const ch of this.channels.values()) { + const logDir = ClassicChannelManager.chatLogDir(ch.instanceName); + if (!existsSync(logDir)) continue; + for (const file of readdirSync(logDir)) { + const match = file.match(/^(\d{4})-(\d{2})-(\d{2})\.log$/); + if (!match) continue; + const fileDate = new Date(+match[1], +match[2] - 1, +match[3]).getTime(); + if (fileDate < cutoff) { unlinkSync(join(logDir, file)); deleted++; } + } + } + if (deleted > 0) this.logger.info({ deleted }, "Rotated classic channel chat logs"); + return deleted; + } +} diff --git a/src/cli.ts b/src/cli.ts index e0835072..88854b11 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,4 +1,8 @@ #!/usr/bin/env node +// Force IPv4 for all DNS lookups (fixes IPv6 timeout issues in corporate/WSL environments) +import dns from "node:dns"; +dns.setDefaultResultOrder("ipv4first"); + import { Command } from "commander"; import { join, dirname } from "node:path"; import { SchedulerDb } from "./scheduler/db.js"; @@ -584,6 +588,114 @@ backend } } + // ── Fleet-level checks ── + console.log(`\n \x1b[1mFleet checks\x1b[0m\n`); + + // fleet.yaml syntax + const fleetYamlPath = join(DATA_DIR, "fleet.yaml"); + if (existsSync(fleetYamlPath)) { + try { + const yaml = await import("js-yaml"); + yaml.default.load(readFileSync(fleetYamlPath, "utf-8")); + ok(`fleet.yaml${" ".repeat(10)} valid syntax`); + } catch (e) { + fail(`fleet.yaml${" ".repeat(10)} parse error: ${(e as Error).message?.split("\n")[0]}`); + } + } else { + fail(`fleet.yaml${" ".repeat(10)} not found at ${fleetYamlPath}`); + } + + // Port 19280 availability + try { + const net = await import("node:net"); + await new Promise((resolve, reject) => { + const srv = net.createServer(); + srv.once("error", reject); + srv.listen(19280, () => { srv.close(); resolve(); }); + }); + ok(`port 19280${" ".repeat(10)} available`); + } catch { + fail(`port 19280${" ".repeat(10)} in use (fleet already running or port conflict)`); + } + + // Stale MCP sockets + const instancesDir = join(DATA_DIR, "instances"); + if (existsSync(instancesDir)) { + const { execSync: execSyncCheck } = await import("node:child_process"); + let stale = 0; + for (const d of readdirSync(instancesDir)) { + const sock = join(instancesDir, d, "channel.mcp.sock"); + if (existsSync(sock)) { + const pidFile = join(instancesDir, d, "daemon.pid"); + if (existsSync(pidFile)) { + const pid = parseInt(readFileSync(pidFile, "utf-8").trim(), 10); + try { process.kill(pid, 0); } catch { stale++; } + } else { stale++; } + } + } + if (stale > 0) fail(`MCP sockets${" ".repeat(9)} ${stale} stale socket(s) found`); + else ok(`MCP sockets${" ".repeat(9)} no stale sockets`); + } + + // Service file ExecStart vs current binary + try { + const { execSync: es } = await import("node:child_process"); + const svcPath = join(homedir(), ".config/systemd/user/com.agend.fleet.service"); + if (existsSync(svcPath)) { + const svc = readFileSync(svcPath, "utf-8"); + const match = svc.match(/ExecStart=(\S+)/); + const svcBin = match?.[1] ?? ""; + const currentBin = es("which agend", { stdio: "pipe" }).toString().trim(); + if (svcBin === currentBin) ok(`service ExecStart${" ".repeat(3)} matches current binary`); + else fail(`service ExecStart${" ".repeat(3)} ${svcBin} ≠ ${currentBin}`); + + // Check Restart=on-failure + if (svc.includes("Restart=on-failure")) { + ok(`service restart${" ".repeat(5)} Restart=on-failure configured`); + } else { + fail(`service restart${" ".repeat(5)} missing Restart=on-failure — run: agend start --install-service`); + } + + // Check if daemon-reload needed + try { + const status = es("systemctl --user show com.agend.fleet --property=NeedDaemonReload", { stdio: "pipe" }).toString().trim(); + if (status.includes("yes")) { + fail(`service reload${" ".repeat(6)} daemon-reload needed — run: systemctl --user daemon-reload`); + } else { + ok(`service reload${" ".repeat(6)} up to date`); + } + } catch { /* systemctl not available */ } + } + } catch { /* no service file or which fails */ } + + // kiro-cli auth + if (backendName === "kiro-cli") { + const kiroDir = join(homedir(), ".kiro"); + if (existsSync(kiroDir)) { + ok(`kiro-cli auth${" ".repeat(6)} ~/.kiro/ exists`); + } else { + fail(`kiro-cli auth${" ".repeat(6)} ~/.kiro/ not found — run: kiro-cli login`); + } + } + + // working_directory existence + if (existsSync(fleetYamlPath)) { + try { + const yaml = await import("js-yaml"); + const fleet = yaml.default.load(readFileSync(fleetYamlPath, "utf-8")) as any; + if (fleet?.instances) { + let missing = 0; + for (const [name, cfg] of Object.entries(fleet.instances) as [string, any][]) { + if (cfg.working_directory && !existsSync(cfg.working_directory)) { + missing++; + fail(`working_dir${" ".repeat(9)} ${name}: ${cfg.working_directory} does not exist`); + } + } + if (missing === 0) ok(`working_dir${" ".repeat(9)} all instance directories exist`); + } + } catch { /* already reported parse error above */ } + } + console.log(); if (issues === 0) { console.log(` \x1b[32m✓ All checks passed\x1b[0m`); @@ -800,57 +912,153 @@ access program .command("update") .description("Update AgEnD to latest version and restart service") - .option("--skip-install", "Skip npm install, only restart service") - .action(async (opts: { skipInstall?: boolean }) => { - const { detectPlatform } = await import("./service-installer.js"); + .option("--version ", "Specific version to install") + .option("--beta", "Install beta version") + .action(async (opts: { version?: string; beta?: boolean }) => { + const { installService, activateService, detectPlatform } = await import("./service-installer.js"); + const { spawnSync, spawn: spawnAsync } = await import("node:child_process"); + const tag = opts.version ? opts.version : (opts.beta ? "beta" : "latest"); + const pkg = `@songsid/agend@${tag}`; + const pluginPkg = `@songsid/agend-plugin-discord@${tag}`; + + console.log(`\n Updating AgEnD to ${tag}...\n`); + + // Detect and remove stale npm link (local build that shadows global install) + try { + const agendPath = execSync("which agend", { encoding: "utf-8", stdio: "pipe" }).trim(); + const resolved = execSync(`readlink -f "${agendPath}"`, { encoding: "utf-8", stdio: "pipe" }).trim(); + if (resolved.includes("/Projects/") || resolved.includes("@suzuke") || resolved.includes("/src/")) { + console.log(` ⚠️ Detected local npm link: ${resolved}`); + console.log(" Removing link to allow global install...\n"); + try { execSync("npm unlink -g @suzuke/agend", { stdio: "pipe", timeout: 15000 }); } catch {} + try { execSync("npm unlink -g @songsid/agend", { stdio: "pipe", timeout: 15000 }); } catch {} + } + } catch { /* which/readlink failed — no agend installed, fine */ } - if (!opts.skipInstall) { - console.log(" Updating AgEnD..."); + // ── Check if npm global needs sudo ── + let needsSudo = false; + try { + const prefix = execSync("npm config get prefix", { encoding: "utf-8" }).trim(); + const { accessSync, constants } = await import("node:fs"); + try { accessSync(prefix, constants.W_OK); } catch { needsSudo = true; } + } catch { /* assume no sudo needed */ } + + if (needsSudo) { + // ── nvm path: install without sudo ── + const nvmDir = join(homedir(), ".nvm"); + const nvmSh = join(nvmDir, "nvm.sh"); + if (!existsSync(nvmSh)) { + console.log(" Installing nvm (npm global requires sudo)..."); + try { + execSync("curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash", { stdio: "inherit" }); + } catch { + console.error(" Failed to install nvm. Install manually: https://github.com/nvm-sh/nvm"); + process.exit(1); + } + } + console.log(" Using nvm to install Node 22..."); + const nvmPrefix = `source ${nvmSh} && nvm install 22 && nvm use 22`; try { - execSync("npm install -g @suzuke/agend@latest", { stdio: "inherit" }); - } catch (err) { - console.error(" Failed to update. Try: npm install -g @suzuke/agend@latest"); + execSync(`bash -c '${nvmPrefix} && npm install -g ${pkg} ${pluginPkg}'`, { stdio: "inherit" }); + } catch { + console.error(" Failed to install via nvm."); + process.exit(1); + } + // Try to remove old system binary + console.log(" Note: removing old system install (may require sudo)..."); + spawnSync("sudo", ["npm", "uninstall", "-g", "@songsid/agend"], { stdio: "inherit" }); + } else { + // ── Direct install ── + try { + execSync(`npm install -g ${pkg} ${pluginPkg}`, { stdio: "inherit" }); + } catch { + console.error(` Failed to update. Try: npm install -g ${pkg}`); process.exit(1); } } - const plat = detectPlatform(); - const label = "com.agend.fleet"; + // ── Verify installation ── + console.log("\n Verifying installation..."); + const nvmSh2 = join(homedir(), ".nvm", "nvm.sh"); + const agendPath = needsSudo + ? spawnSync("bash", ["-c", `source ${nvmSh2} && nvm use 22 > /dev/null 2>&1 && which agend`], { encoding: "utf-8" }).stdout?.trim() + : spawnSync("which", ["agend"], { encoding: "utf-8" }).stdout?.trim(); + if (!agendPath) { + console.error(" ✗ Verification failed: agend not found in PATH after install."); + if (needsSudo) console.error(" You may need to add nvm to your shell profile and restart."); + process.exit(1); + } + const verifyResult = spawnSync(agendPath, ["--version"], { encoding: "utf-8", timeout: 5000 }); + if (verifyResult.status !== 0) { + console.error(" ✗ Verification failed: agend --version returned error."); + process.exit(1); + } + const newVersion = (verifyResult.stdout ?? "").trim(); + console.log(` ✓ Installed: ${newVersion}`); - if (plat === "macos") { - const plistPath = join(homedir(), "Library/LaunchAgents", `${label}.plist`); - if (existsSync(plistPath)) { - const uid = process.getuid?.() ?? 501; - console.log(" Restarting launchd service..."); - try { - execSync(`launchctl kickstart -k gui/${uid}/${label}`, { stdio: "inherit" }); - console.log(" ✓ Service restarted with new version"); - } catch { - console.log(" Failed to restart service. Try: launchctl kickstart -k gui/" + uid + "/" + label); + // ── Update service file ── + const plat = detectPlatform(); + if (agendPath) { + try { + // Use the NEW binary to install service (old binary's templates may be deleted) + const installResult = spawnSync(agendPath, ["install"], { encoding: "utf-8", timeout: 15000 }); + if (installResult.status === 0) { + console.log(` ✓ Service updated`); + } else { + console.log(` ⚠ Service file update failed (non-fatal): ${(installResult.stderr || installResult.stdout || "unknown error").trim()}`); } - return; + } catch (e) { + console.log(` ⚠ Service file update failed (non-fatal): ${(e as Error).message}`); } - } else { - try { - execSync(`systemctl --user restart ${label}`, { stdio: "inherit" }); - console.log(" ✓ Service restarted with new version"); - return; - } catch { /* no systemd service */ } } - // Fallback: signal running daemon + // ── Restart fleet ── const pidPath = join(DATA_DIR, "fleet.pid"); - if (existsSync(pidPath)) { - const pid = parseInt(readFileSync(pidPath, "utf-8").trim(), 10); - try { - process.kill(pid, "SIGUSR1"); - console.log(" ✓ Sent restart signal to running fleet (PID " + pid + ")"); - } catch { - console.log(" Fleet not running. Start with: agend fleet start"); + if (!existsSync(pidPath)) { + console.log("\n Fleet not running. Start with: agend start\n"); + return; + } + + // Kill old fleet process first to prevent duplicate + const oldPid = parseInt(readFileSync(pidPath, "utf-8").trim(), 10); + if (oldPid) { + try { process.kill(oldPid, "SIGTERM"); } catch { /* already gone */ } + // Wait up to 10s for old process to exit + for (let i = 0; i < 20; i++) { + try { process.kill(oldPid, 0); } catch { break; } + spawnSync("sleep", ["0.5"]); } + // Force kill if still alive + try { process.kill(oldPid, "SIGKILL"); } catch { /* already gone */ } + try { unlinkSync(pidPath); } catch { /* best effort */ } + } + + console.log(" Restarting fleet..."); + if (plat === "macos") { + const uid = process.getuid?.() ?? 501; + const label = "com.agend.fleet"; + try { + execSync(`launchctl kickstart -k gui/${uid}/${label}`, { stdio: "inherit", timeout: 15000 }); + console.log(" ✓ Service restarted\n"); + return; + } catch { /* fall through to manual restart */ } } else { - console.log(" No service or running fleet found. Start with: agend fleet start"); + try { + execSync("systemctl --user daemon-reload", { stdio: "pipe", timeout: 5000 }); + // Reset failed state in case the kill above left systemd confused + try { execSync("systemctl --user reset-failed com.agend.fleet", { stdio: "pipe", timeout: 5000 }); } catch {} + execSync("systemctl --user start com.agend.fleet", { stdio: "inherit", timeout: 15000 }); + console.log(" ✓ Service restarted\n"); + return; + } catch { /* fall through */ } } + + // Fallback: start directly using new binary + const child = spawnAsync(agendPath!, ["fleet", "start"], { + detached: true, stdio: "ignore", + }); + child.unref(); + console.log(" ✓ Fleet restarting in background\n"); }); program @@ -859,7 +1067,7 @@ program .action(async () => { const pidPath = join(DATA_DIR, "fleet.pid"); if (!existsSync(pidPath)) { - console.error("Fleet is not running. Start with: agend fleet start"); + console.error("Fleet is not running. Start with: agend start"); process.exit(1); } const pid = parseInt(readFileSync(pidPath, "utf-8").trim(), 10); @@ -936,7 +1144,14 @@ program if (stopService()) { console.log("Service stopped."); } else { - console.log("Service is not running or already stopped."); + // systemd/launchd failed (e.g. no D-Bus user session) — fallback to PID kill + const pidPath = join(DATA_DIR, "fleet.pid"); + if (existsSync(pidPath)) { + const pid = parseInt(readFileSync(pidPath, "utf-8").trim(), 10); + try { process.kill(pid, "SIGTERM"); console.log(`Stopped fleet via PID (${pid}). Service manager unavailable.`); } catch { console.log("Fleet not running."); } + } else { + console.log("Service is not running or already stopped."); + } } }); @@ -946,14 +1161,22 @@ program .action(async () => { const { getServicePath, startService } = await import("./service-installer.js"); if (!getServicePath()) { - console.log("No service installed. Run: agend install"); - console.log("Or start manually: agend fleet start"); + console.log("No service installed. Starting fleet directly..."); + const { spawn } = await import("node:child_process"); + const child = spawn("sh", ["-c", "agend fleet start"], { detached: true, stdio: "ignore" }); + child.unref(); + console.log("Fleet starting in background."); return; } if (startService()) { console.log("Service started."); } else { - console.log("Failed to start service. Check: agend backend doctor"); + // systemd/launchd failed — fallback to direct start + console.log("Service manager unavailable. Starting fleet directly..."); + const { spawn } = await import("node:child_process"); + const child = spawn("sh", ["-c", "agend fleet start"], { detached: true, stdio: "ignore" }); + child.unref(); + console.log("Fleet starting in background."); } }); @@ -1284,6 +1507,20 @@ async function fuzzyMatch(query: string, names: string[]): Promise { const names = Object.keys(config.instances); + // Include classic instances from classicBot.yaml + try { + const classicPath = join(DATA_DIR, "classicBot.yaml"); + if (existsSync(classicPath)) { + const yamlMod = (await import("js-yaml")).default; + const classic = yamlMod.load(readFileSync(classicPath, "utf-8")) as { channels?: Record } | null; + if (classic?.channels) { + for (const [channelId, val] of Object.entries(classic.channels)) { + const chName = (val.name ?? channelId).toLowerCase().replace(/[^\p{L}\d-]/gu, "-").replace(/-+/g, "-").replace(/^-|-$/g, "") || "project"; + names.push(`classic-${chName}-${channelId.slice(-4)}`); + } + } + } + } catch { /* ignore */ } const match = await fuzzyMatch(query, names); if (!match) { console.error(`No instance matching "${query}". Available: ${names.join(", ")}`); @@ -1298,9 +1535,21 @@ function getTreeRssKb(pid: number, depth = 0): number { if (!Number.isInteger(pid) || pid <= 0) return 0; let total = 0; try { - const rss = parseInt(execFileSync("ps", ["-o", "rss=", "-p", String(pid)], { stdio: "pipe" }).toString().trim(), 10); - if (!isNaN(rss)) total += rss; - } catch { return 0; } + // Try PSS from smaps_rollup (more accurate — excludes shared page double-counting) + const smaps = readFileSync(`/proc/${pid}/smaps_rollup`, "utf-8"); + const match = smaps.match(/^Pss:\s+(\d+)/m); + if (match) { + total += parseInt(match[1], 10); + } else { + throw new Error("no Pss line"); + } + } catch { + // Fallback to RSS via ps + try { + const rss = parseInt(execFileSync("ps", ["-o", "rss=", "-p", String(pid)], { stdio: "pipe" }).toString().trim(), 10); + if (!isNaN(rss)) total += rss; + } catch { return 0; } + } try { const children = execFileSync("pgrep", ["-P", String(pid)], { stdio: "pipe" }).toString().trim(); for (const line of children.split("\n")) { @@ -1353,8 +1602,8 @@ function formatTimeSince(isoStr: string): string { /** Backend-specific parsers for extracting context usage from tmux pane output. */ const contextParsers: Record number | null> = { "kiro-cli": (output) => { - // Classic mode: "8% !>" | TUI mode: "◔ 1%" - const m = output.match(/(\d+)%\s*!>\s*$/m) || output.match(/◔\s*(\d+)%/); + // Classic mode: "8% !>" | TUI mode: "◔ 1%" | Bracket: "[8%]" | Prompt: "8% ❯" + const m = output.match(/(\d+)%.*[!❯>]/m) || output.match(/◔\s*(\d+)%/) || output.match(/\[(\d+)%\]/); return m ? parseInt(m[1], 10) : null; }, }; @@ -1364,7 +1613,33 @@ async function lsAction(opts: { json?: boolean }): Promise { const config = yaml.load(readFileSync(FLEET_CONFIG_PATH, "utf-8")) as import("./types.js").FleetConfig; const names = Object.keys(config.instances); - if (names.length === 0) { + // Load classic channels from classicBot.yaml (keyed by channelId) + const classicPath = join(DATA_DIR, "classicBot.yaml"); + interface ClassicEntry { name?: string; backend?: string; createdBy?: string; createdAt?: string } + interface ClassicBotConfig { defaults?: { backend?: string }; channels?: Record } + let classicConfig: ClassicBotConfig | null = null; + try { + if (existsSync(classicPath)) classicConfig = yaml.load(readFileSync(classicPath, "utf-8")) as ClassicBotConfig; + } catch { /* ignore */ } + + const allNames = [...names]; + const classicNames = new Set(); + const classicBackends = new Map(); + if (classicConfig?.channels) { + const classicDefault = classicConfig.defaults?.backend || config.defaults?.backend || "claude-code"; + for (const [channelId, val] of Object.entries(classicConfig.channels)) { + const chName = (val.name ?? channelId).toLowerCase().replace(/[^\p{L}\d-]/gu, "-").replace(/-+/g, "-").replace(/^-|-$/g, "") || "project"; + const suffix = channelId.slice(-4); + const iName = `classic-${chName}-${suffix}`; + if (!allNames.includes(iName)) { + allNames.push(iName); + classicNames.add(iName); + classicBackends.set(iName, val.backend || classicDefault); + } + } + } + + if (allNames.length === 0) { console.log("No instances configured."); return; } @@ -1382,11 +1657,40 @@ async function lsAction(opts: { json?: boolean }): Promise { } } catch { /* tmux not running */ } - const rows = names.map(name => { + // Determine platform source for each instance + const channelTypes = new Set(); + if (config.channels?.length) { + for (const ch of config.channels) channelTypes.add(ch.type); + } else if (config.channel?.type) { + channelTypes.add(config.channel.type); + } + const singleSource = channelTypes.size === 1 + ? (channelTypes.has("telegram") ? "TG" : channelTypes.has("discord") ? "DC" : "—") + : null; + + const getSource = (name: string, inst?: Record): string => { + if (singleSource) return singleSource; + if (classicNames.has(name)) { + // Match channelId by suffix: Telegram IDs are short/negative, Discord snowflakes are 17+ digits + const suffix = name.slice(-4); + const matchedChId = Object.keys(classicConfig?.channels ?? {}).find(id => id.slice(-4) === suffix) ?? ""; + return matchedChId.startsWith("-") || matchedChId.length < 17 ? "TG" : "DC"; + } + const topicId = String(inst?.topic_id ?? ""); + if (topicId.length >= 17) return "DC"; + if (topicId.length > 0 && topicId.length < 17) return "TG"; + return "—"; + }; + + const rows = allNames.map(name => { + const isClassic = classicNames.has(name); const status = getInstanceStatusStandalone(name); - const teams = getTeamsForInstance(config, name); + const teams = isClassic ? ["(classic)"] : getTeamsForInstance(config, name); const inst = config.instances[name]; - const backend = (inst as unknown as Record)?.backend as string ?? config.defaults?.backend ?? "claude-code"; + const backend = isClassic + ? (classicBackends.get(name) ?? "claude-code") + : ((inst as unknown as Record)?.backend as string ?? config.defaults?.backend ?? "claude-code"); + const source = getSource(name, inst as unknown as Record); // Read statusline for context let context: number | null = null; @@ -1405,7 +1709,7 @@ async function lsAction(opts: { json?: boolean }): Promise { try { const pane = execFileSync("tmux", tmuxArgs([ "capture-pane", "-t", `${sessionName}:${name}`, "-p" - ]), { encoding: "utf-8", timeout: 2000 }); + ]), { encoding: "utf-8", timeout: 2000, stdio: ["pipe", "pipe", "pipe"] }); context = parser(pane); } catch { /* tmux capture failed */ } } @@ -1433,7 +1737,7 @@ async function lsAction(opts: { json?: boolean }): Promise { } catch { /* ignore */ } } - return { name, backend, status, teams, context, memMb, lastActivity }; + return { name, backend, status, teams, source, context, memMb, lastActivity }; }); if (opts.json) { @@ -1445,10 +1749,22 @@ async function lsAction(opts: { json?: boolean }): Promise { const statusIcon = (s: string) => s === "running" ? "\x1b[32m●\x1b[0m" : s === "crashed" ? "\x1b[31m●\x1b[0m" : "\x1b[90m○\x1b[0m"; - const nameW = Math.max(20, ...rows.map(r => r.name.length + 2)); + /** Get display width accounting for fullwidth (CJK) characters */ + const displayWidth = (s: string): number => { + let w = 0; + for (const ch of s) { + const cp = ch.codePointAt(0)!; + w += (cp > 0x7f && cp !== 0x200b) ? 2 : 1; + } + return w; + }; + const padDisplay = (s: string, width: number): string => s + " ".repeat(Math.max(0, width - displayWidth(s))); + + const nameW = Math.max(20, ...rows.map(r => displayWidth(r.name) + 2)); const backendW = 14; const statusW = 12; const teamW = 20; + const srcW = 4; const ctxW = 8; const memW = 8; @@ -1457,11 +1773,12 @@ async function lsAction(opts: { json?: boolean }): Promise { "Backend".padEnd(backendW) + "Status".padEnd(statusW) + "Team".padEnd(teamW) + + "Src".padEnd(srcW) + "Ctx".padEnd(ctxW) + "Mem".padEnd(memW) + "Activity" ); - console.log("\u2500".repeat(nameW + backendW + statusW + teamW + ctxW + memW + 10)); + console.log("\u2500".repeat(nameW + backendW + statusW + teamW + srcW + ctxW + memW + 10)); for (const r of rows) { const teamStr = r.teams.length > 0 ? r.teams.join(",") : "-"; @@ -1470,10 +1787,11 @@ async function lsAction(opts: { json?: boolean }): Promise { const actStr = r.lastActivity ?? "-"; console.log( - r.name.padEnd(nameW) + + padDisplay(r.name, nameW) + r.backend.padEnd(backendW) + statusIcon(r.status) + " " + r.status.padEnd(statusW - 2) + - teamStr.padEnd(teamW) + + padDisplay(teamStr, teamW) + + r.source.padEnd(srcW) + ctxStr.padEnd(ctxW) + memStr.padEnd(memW) + actStr @@ -1481,9 +1799,11 @@ async function lsAction(opts: { json?: boolean }): Promise { } // System memory footer + const totalMemMb = rows.reduce((sum, r) => sum + (r.memMb ?? 0), 0); + const runningCount = rows.filter(r => r.status === "running").length; const totalGB = totalmem() / (1024 ** 3); const usedGB = (totalmem() - freemem()) / (1024 ** 3); - console.log(`\nSystem Memory: ${usedGB.toFixed(1)} / ${totalGB.toFixed(1)} GB`); + console.log(`\nInstances: ${runningCount} running | Fleet Mem: ${(totalMemMb / 1024).toFixed(1)} GB | System Memory: ${usedGB.toFixed(1)} / ${totalGB.toFixed(1)} GB`); } program @@ -1661,7 +1981,7 @@ program const status = getInstanceStatusStandalone(name); if (status !== "running") { - console.error(`Instance "${name}" is ${status}. Start it first with: agend fleet start ${name}`); + console.error(`Instance "${name}" is ${status}. Start it first with: agend start ${name}`); process.exit(1); } @@ -1693,6 +2013,20 @@ program try { execFileSync("tmux", tmuxArgs(["select-window", "-t", t]), { stdio: "pipe" }); selected = true; break; } catch { /* try next */ } } + // Fallback: search tmux windows for a partial name match (handles CJK truncation) + if (!selected) { + try { + const winList = execFileSync("tmux", tmuxArgs(["list-windows", "-t", session, "-F", "#{window_id} #{window_name}"]), { stdio: "pipe" }).toString().trim(); + for (const line of winList.split("\n")) { + const [wid, ...rest] = line.split(" "); + const wname = rest.join(" ").replace(/-?\*?$/, ""); // strip trailing -/* markers + if (wname === name || name.startsWith(wname) || wname.startsWith(name)) { + try { execFileSync("tmux", tmuxArgs(["select-window", "-t", `${session}:${wid}`]), { stdio: "pipe" }); selected = true; break; } + catch { /* try next */ } + } + } + } catch { /* ignore */ } + } if (!selected) { console.error(`Cannot find tmux window for "${name}". The instance may need to be restarted.`); process.exit(1); diff --git a/src/config.ts b/src/config.ts index 555dc0db..18330895 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,7 +1,7 @@ import { readFileSync, existsSync, mkdirSync } from "node:fs"; import { join } from "node:path"; import yaml from "js-yaml"; -import { getAgendHome, getTmuxSessionName } from "./paths.js"; +import { getAgendHome, getTmuxSessionName, ensureWorkspaceGit } from "./paths.js"; import type { CostGuardConfig, HangDetectorConfig, DailySummaryConfig, FleetConfig, FleetTemplate, InstanceConfig } from "./types.js"; function deepMergeGeneric(target: T, source: Partial): T { @@ -85,6 +85,7 @@ export function loadFleetConfig(configPath: string): FleetConfig { const parsed = yaml.load(safeRaw) as { channel?: FleetConfig["channel"]; + channels?: FleetConfig["channels"]; project_roots?: string[]; defaults?: Partial; instances?: Record>; @@ -111,6 +112,7 @@ export function loadFleetConfig(configPath: string): FleetConfig { if (!merged.working_directory) { const defaultDir = join(getAgendHome(), "workspaces", name); mkdirSync(defaultDir, { recursive: true }); + ensureWorkspaceGit(defaultDir); merged.working_directory = defaultDir; } @@ -128,17 +130,20 @@ export function loadFleetConfig(configPath: string): FleetConfig { } } + // Normalize channel → channels (backward compat: single channel auto-wraps into array) + const channelSingle = parsed.channel + ? { ...parsed.channel, id: parsed.channel.id ?? parsed.channel.type, mode: parsed.channel.mode ?? "topic" as const } + : undefined; + const channels: FleetConfig["channels"] = parsed.channels + ? parsed.channels.map(ch => ({ ...ch, id: ch.id ?? ch.type, mode: ch.mode ?? "topic" as const })) + : channelSingle ? [channelSingle] : undefined; + + // Always set channel to first entry (fallback for code using fleetConfig.channel) + const primaryChannel = channelSingle ?? (channels?.[0] ?? undefined); + return { - channel: parsed.channel - ? (() => { - if (!parsed.channel.mode) { - throw new Error( - `fleet.yaml: channel.mode is required. Valid values: "topic" | "classic"` - ); - } - return parsed.channel; - })() - : parsed.channel, + channel: primaryChannel, + channels, project_roots: parsed.project_roots, defaults: fleetDefaults, instances, diff --git a/src/cost-guard.ts b/src/cost-guard.ts index 1e303095..63ef5f0f 100644 --- a/src/cost-guard.ts +++ b/src/cost-guard.ts @@ -13,13 +13,38 @@ export function formatCents(cents: number): string { return `$${(cents / 100).toFixed(2)}`; } -function msUntilMidnight(timezone: string): number { - const now = new Date(); - const tzNow = new Date(now.toLocaleString("en-US", { timeZone: timezone })); - const tzMidnight = new Date(tzNow); - tzMidnight.setHours(24, 0, 0, 0); - const diff = tzMidnight.getTime() - tzNow.getTime(); - return diff > 0 ? diff : 24 * 60 * 60 * 1000; +/** + * Return ms until the next calendar-date change in `timezone`. + * + * The previous implementation used `new Date(now.toLocaleString(..., {timeZone}))` + * (which reinterprets the target-tz wall-clock as a local-tz wall-clock — wrong + * across DST whenever local and target observe DST differently) and then called + * `setHours(24, 0, 0, 0)` (which is local-tz, so on a DST transition day in the + * local zone the gap is off by an hour). + * + * Instead we observe the *actual* date in the target tz via Intl and binary-search + * for the first instant where that date changes. This naturally handles 23-hour + * spring-forward and 25-hour fall-back days, and also handles non-DST mismatches + * between local and target zones. + */ +export function msUntilMidnight(timezone: string): number { + const fmt = new Intl.DateTimeFormat("en-CA", { + timeZone: timezone, + year: "numeric", month: "2-digit", day: "2-digit", + }); + const dateInTz = (t: number) => fmt.format(new Date(t)); + + const now = Date.now(); + const today = dateInTz(now); + // 26h covers the worst-case 25-hour fall-back day plus a 1h safety margin. + let lo = 1; + let hi = 26 * 60 * 60 * 1000; + while (lo < hi) { + const mid = Math.floor((lo + hi) / 2); + if (dateInTz(now + mid) === today) lo = mid + 1; + else hi = mid; + } + return lo; } export class CostGuard extends EventEmitter { @@ -86,6 +111,14 @@ export class CostGuard extends EventEmitter { tracker.accumulatedCents += sessionCents; const previousUsd = tracker.lastReportedUsd; tracker.lastReportedUsd = 0; + // Reset per-day notification flags so a new session that pushes the + // accumulated total past the threshold re-fires `warn` / `limit`. This + // matters for the limit handler in particular: it pauses the instance, + // and without re-firing a user-restarted instance can blow past the + // daily cap again silently. We're still bounded — a single session + // can fire each event at most once. + tracker.warnEmitted = false; + tracker.limitEmitted = false; this.eventLog.insert(instance, "cost_snapshot", { session_cost_usd: previousUsd, diff --git a/src/daemon.ts b/src/daemon.ts index 261f38ff..18c575fa 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -1,7 +1,8 @@ import { join, dirname, basename, resolve } from "node:path"; -import { mkdirSync, writeFileSync, readFileSync, existsSync, unlinkSync, rmSync, appendFileSync, statSync } from "node:fs"; +import { mkdirSync, writeFileSync, readFileSync, existsSync, unlinkSync, rmSync, appendFileSync, statSync, chmodSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { homedir } from "node:os"; +import { randomBytes } from "node:crypto"; import { EventEmitter } from "node:events"; import type { InstanceConfig, RotationSnapshot, RotationSnapshotEvent } from "./types.js"; import { createLogger, type Logger } from "./logger.js"; @@ -12,6 +13,7 @@ import { IpcServer } from "./channel/ipc-bridge.js"; import { MessageBus } from "./channel/message-bus.js"; import { ToolTracker } from "./channel/tool-tracker.js"; import type { CliBackend, CliBackendConfig, ErrorPattern, StartupDialog } from "./backend/types.js"; +import { shellQuote } from "./backend/types.js"; import type { ChannelAdapter, InboundMessage } from "./channel/types.js"; import { getTmuxSession } from "./config.js"; import { routeToolCall } from "./channel/tool-router.js"; @@ -23,7 +25,7 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); // Tool routing sets — module-level to avoid re-creation on every handleToolCall -const CROSS_INSTANCE_TOOLS = new Set(["send_to_instance", "list_instances", "start_instance", "create_instance", "delete_instance", "replace_instance", "request_information", "delegate_task", "report_result", "describe_instance"]); +const CROSS_INSTANCE_TOOLS = new Set(["send_to_instance", "list_instances", "start_instance", "restart_instance", "create_instance", "delete_instance", "replace_instance", "request_information", "delegate_task", "report_result", "describe_instance"]); const SCHEDULE_TOOLS = new Set(["create_schedule", "list_schedules", "update_schedule", "delete_schedule"]); const DECISION_TOOLS = new Set(["post_decision", "list_decisions", "update_decision"]); const TASK_TOOL = "task"; @@ -77,6 +79,8 @@ export class Daemon extends EventEmitter { private snapshotConsumed = false; private pasteLock: Promise = Promise.resolve(); private pendingInstructionsUpdate: string | undefined; + private pendingInstructionsNotice = false; + private pasteQueueDepth = 0; // PTY error pattern monitoring private errorMonitorTimer: ReturnType | null = null; private errorWaitingForRecovery = false; // true = error detected, waiting for ready pattern @@ -209,6 +213,18 @@ export class Daemon extends EventEmitter { if (meta.chat_id) this.lastChatId = meta.chat_id; if (meta.chat_id && meta.thread_id) this.lastThreadId = meta.thread_id; this.pushChannelMessage(msg.content as string, meta, targetSession); + } else if (msg.type === "raw_paste") { + // Paste raw text directly to CLI without [user:] wrapping. + // Use pasteLock to serialize with other deliveries and wait for idle. + if (this.tmux) { + const rawText = msg.content as string; + this.pasteLock = this.pasteLock.then(async () => { + await this.deliverMessage(rawText); + this.logger.debug({ text: rawText.slice(0, 100) }, "Raw paste delivered"); + }).catch(err => { + this.logger.warn({ err: (err as Error).message }, "raw_paste delivery error"); + }); + } } else if (msg.type === "fleet_schedule_trigger") { const payload = msg.payload as Record; const meta = msg.meta as Record; @@ -311,8 +327,8 @@ export class Daemon extends EventEmitter { // 10. Health check — detect crashed tmux window and respawn // Re-enabled: orphan window issue fixed by killing same-name windows before respawn. // Without this, a dead CLI window goes undetected and messages are silently lost. + this.startHealthCheck(); if (!this.config.lightweight) { - this.startHealthCheck(); this.startErrorMonitor(); } @@ -325,6 +341,16 @@ export class Daemon extends EventEmitter { const scheduleNext = () => { this.healthCheckTimer = setTimeout(async () => { + // Instance directory removed externally (e.g. `rm -rf ~/.agend/instances/`). + // Stop the loop permanently — otherwise every tick triggers a respawn, whose + // writeRotationSnapshot fails with ENOENT and gets caught as "Failed to respawn", + // spamming errors every ~30s forever. + if (!existsSync(this.instanceDir)) { + this.logger.warn({ instanceDir: this.instanceDir }, "Instance directory missing — stopping health check"); + this.healthCheckPaused = true; + this.healthCheckTimer = null; + return; + } if (!this.tmux || this.spawning || this.healthCheckPaused || Daemon.tmuxServerPaused) { scheduleNext(); return; @@ -532,10 +558,10 @@ export class Daemon extends EventEmitter { for (const dialog of dialogs) { if (!dialog.pattern.test(pane)) continue; this.logger.info(`Auto-dismissing runtime dialog: ${dialog.description}`); - const SPECIAL_KEYS = new Set(["Up", "Down", "Enter", "Escape"]); + const SPECIAL_KEYS = new Set(["Up", "Down", "Enter", "Escape", "Right", "Left"]); for (const key of dialog.keys) { if (SPECIAL_KEYS.has(key)) { - await this.tmux.sendSpecialKey(key as "Enter" | "Escape" | "Up" | "Down"); + await this.tmux.sendSpecialKey(key as "Enter" | "Escape" | "Up" | "Down" | "Right" | "Left"); } else { await this.tmux.pasteText(key); } @@ -598,7 +624,7 @@ export class Daemon extends EventEmitter { } catch { // capturePane can fail if window is transitioning — ignore } - }, 30_000); // Check every 30 seconds + }, 5_000); // Check every 5 seconds (runtime dialogs need fast response) } async stop(): Promise { @@ -746,12 +772,26 @@ export class Daemon extends EventEmitter { // Format message with metadata prefix for the agent const user = meta.user || "unknown"; const fromInstance = meta.from_instance; + + // /raw prefix: paste directly without [user:] wrapping (topic mode only, protected by allowed_users upstream) + if (!fromInstance && content.startsWith("/raw ")) { + const rawText = content.slice(5); + this.logger.info({ user }, "Raw paste from topic mode user"); + this.pasteLock = this.pasteLock.then(async () => { + await this.deliverMessage(rawText); + }).catch(err => { + this.logger.warn({ err: (err as Error).message }, "pasteLock raw delivery error"); + }); + return; + } + let formatted: string; if (fromInstance) { - formatted = `[from:${fromInstance}] ${content}\n(Reply using send_to_instance tool, NOT direct text)`; + formatted = `[from:${fromInstance}] ${content}\n(If you need to reply, use send_to_instance tool, NOT direct text. If there is nothing to add, you may stay silent.)`; } else { const via = meta.source ? ` via ${meta.source}` : ""; - formatted = `[user:${user}${via}] ${content}\n(Reply using the reply tool — do NOT respond with direct text)`; + const idTag = meta.user_id ? `, id:${meta.user_id}` : ""; + formatted = `[user:${user}${via}${idTag}] ${content}\n(Reply using the reply tool — do NOT respond with direct text)`; } if (meta.reply_to_text) { formatted += `\n(reply_to: "${meta.reply_to_text}")`; @@ -759,7 +799,40 @@ export class Daemon extends EventEmitter { // Serialize deliveries: each message waits for the previous to complete, // and each waits for the CLI to be idle before pasting. - this.pasteLock = this.pasteLock.then(() => this.deliverMessage(formatted)).catch(err => { + const enqueuedAt = Date.now(); + const isFromInstance = !!meta.from_instance; + const chatId = meta.chat_id; + const messageId = meta.message_id; + const wasQueued = this.pasteQueueDepth > 0; + this.pasteQueueDepth++; + if (this.pasteQueueDepth > 3) { + this.logger.warn({ depth: this.pasteQueueDepth }, "Message delivery queue backing up"); + } + if (wasQueued && chatId && messageId) { + this.emit("message_queued", { chatId: meta.thread_id || chatId, messageId }); + } + this.pasteLock = this.pasteLock.then(async () => { + try { + // Drop stale user messages (>60s in queue), but never drop cross-instance messages + if (!isFromInstance && Date.now() - enqueuedAt > 60_000) { + this.logger.warn({ age: Date.now() - enqueuedAt, user: meta.user }, "Dropping stale message"); + return; + } + if (this.config.pre_task_command) { + await this.deliverMessage(this.config.pre_task_command); + } + if (this.pendingInstructionsNotice) { + this.pendingInstructionsNotice = false; + await this.deliverMessage("[system] Your instructions/steering files have been updated. Please re-read them for the latest guidelines."); + } + await this.deliverMessage(formatted); + if (chatId && messageId) { + this.emit("message_delivered", { chatId: meta.thread_id || chatId, messageId }); + } + } finally { + this.pasteQueueDepth--; + } + }).catch(err => { this.logger.warn({ err: (err as Error).message }, "pasteLock delivery error — chain continues"); }); this.logger.debug({ user: meta.user, text: content.slice(0, 100) }, "Queued channel message for delivery"); @@ -769,7 +842,7 @@ export class Daemon extends EventEmitter { private async deliverMessage(formatted: string): Promise { const windowId = this.getWindowId(); if (windowId && this.controlClient) { - const idle = await this.controlClient.waitForIdle(windowId); + const idle = await this.controlClient.waitForIdle(windowId, this.config.lightweight ? 30_000 : 120_000); if (!idle) { this.logger.warn("Delivering message after idle timeout (CLI may be busy)"); } @@ -963,12 +1036,16 @@ export class Daemon extends EventEmitter { // chat_id and thread_id are not exposed in the tool schema — daemon is solely responsible for routing. // Must run before IPC forwarding so topic-mode (fleet manager) also receives the correct chat_id. if (["reply", "react", "edit_message"].includes(tool)) { - if (!this.lastChatId) { + const adapters = this.messageBus.getAllAdapters(); + const isTopicMode = adapters.length === 0; + if (!this.lastChatId && !isTopicMode) { respond(null, "No active chat context — awaiting inbound message"); return; } - args.chat_id = this.lastChatId; - if (tool === "reply") args.thread_id = this.lastThreadId; + if (this.lastChatId) { + args.chat_id = this.lastChatId; + if (tool === "reply") args.thread_id = this.lastThreadId; + } } // Route to adapter via MessageBus @@ -1001,7 +1078,7 @@ export class Daemon extends EventEmitter { /** Build config object for the CLI backend */ private buildBackendConfig(): CliBackendConfig { - const isCliMode = this.config.agent_mode === "cli"; + const isCliMode = this.config.agent_mode === "cli" || (this.config.agent_mode == null && this.config.backend === "antigravity"); const sockPath = join(this.instanceDir, "channel.sock"); let serverJs = join(__dirname, "channel", "mcp-server.js"); if (!existsSync(serverJs)) { @@ -1205,16 +1282,16 @@ export class Daemon extends EventEmitter { private async trySpawn(): Promise { const backendConfig = this.buildBackendConfig(); - // Detect instructions change → force new session for backends that don't - // re-read instruction files on --resume (Codex, Gemini, Kiro). + // Detect instructions change → notify agent on next message instead of + // forcing a new session. Resume is preserved so context isn't lost. if (!backendConfig.skipResume && !this.backend!.instructionsReloadedOnResume && backendConfig.instructions) { const prevFile = join(this.instanceDir, "prev-instructions"); let prev = ""; try { prev = readFileSync(prevFile, "utf-8"); } catch {} if (prev !== backendConfig.instructions) { if (prev) { - this.logger.info("Instructions changed — skipping resume to reload"); - backendConfig.skipResume = true; + this.logger.info("Instructions changed — will notify agent on next message"); + this.pendingInstructionsNotice = true; } this.pendingInstructionsUpdate = backendConfig.instructions; } @@ -1222,7 +1299,25 @@ export class Daemon extends EventEmitter { this.backend!.writeConfig(backendConfig); this.backend!.preTrust?.(this.config.working_directory); - let envPrefix = `TERM=xterm-256color AGEND_INSTANCE_NAME=${this.name}`; + + // Resolve working directory (e.g. symlink for hidden paths) + const resolvedCwd = this.backend!.resolveWorkingDirectory?.(this.config.working_directory, this.name) ?? this.config.working_directory; + + // Generate a fresh per-instance agent token each spawn. agent-cli reads + // this file from /agent.token (mode 0o600) and sends its + // value in the X-Agend-Instance-Token header; the daemon-side /agent + // endpoint verifies it matches the on-disk value for the claimed + // instance. This prevents other local processes (even those holding + // the global web token) from impersonating instances. + const agentTokenPath = join(this.instanceDir, "agent.token"); + const agentToken = randomBytes(32).toString("hex"); + writeFileSync(agentTokenPath, agentToken, { mode: 0o600 }); + try { chmodSync(agentTokenPath, 0o600); } catch {} + + // AGEND_HOME points the child's agent-cli at the same data dir the daemon + // is using, so it can locate /agent.token. + const agendHome = join(this.instanceDir, "..", ".."); + let envPrefix = `TERM=xterm-256color AGEND_INSTANCE_NAME=${shellQuote(this.name)} AGEND_HOME=${shellQuote(agendHome)}`; if (backendConfig.agentMode === "cli" && backendConfig.agentPort) { envPrefix += ` AGEND_PORT=${backendConfig.agentPort}`; } @@ -1230,7 +1325,7 @@ export class Daemon extends EventEmitter { // Ensure tmux session exists (may have been destroyed if all windows died) await TmuxManager.ensureSession(this.tmuxSessionName); - const windowId = await this.tmux!.createWindow(cmd, this.config.working_directory, this.name); + const windowId = await this.tmux!.createWindow(cmd, resolvedCwd, this.name); writeFileSync(join(this.instanceDir, "window-id"), windowId); // Enable remain-on-exit to capture exit codes on crash @@ -1245,8 +1340,14 @@ export class Daemon extends EventEmitter { const outputTimeout = Math.round(total * 0.6); const idleTimeout = total - outputTimeout; const hasOutput = await this.controlClient.waitForOutput(windowId, outputTimeout); - if (!hasOutput) return false; - await this.controlClient.waitForIdle(windowId, idleTimeout); + if (!hasOutput) { + // Fallback: some TUI backends (e.g. opencode) don't trigger tmux %output events. + // Check pane content directly for ready pattern before giving up. + const pane = await this.tmux!.capturePane(); + if (!this.backend!.getReadyPattern().test(pane)) return false; + } else { + await this.controlClient.waitForIdle(windowId, idleTimeout); + } } else { await new Promise(r => setTimeout(r, 10_000)); } @@ -1511,7 +1612,9 @@ export class Daemon extends EventEmitter { const branch = (args.branch as string) || "HEAD"; // Validate branch ref: git refs allow [A-Za-z0-9._/-], reject `..` to prevent // worktreePath escape via basename(source)-${branch.replace("/", "-")}. - if (!/^[A-Za-z0-9._/-]+$/.test(branch) || branch.includes("..")) { + // Reject leading `-` or `+` so git cannot interpret the value as an option + // flag (e.g. `--upload-pack=...`), which execFile cannot prevent on its own. + if (!/^[A-Za-z0-9._/-]+$/.test(branch) || branch.includes("..") || /^[-+]/.test(branch)) { respond(null, `Invalid branch name: ${branch}`); return; } @@ -1530,8 +1633,9 @@ export class Daemon extends EventEmitter { const worktreePath = join(repoDir, safeName); try { - // Resolve branch/ref to verify it exists - await execFileAsync("git", ["rev-parse", "--verify", branch], { cwd: source }); + // Resolve branch/ref to verify it exists. Use `--` so git never treats + // branch as an option flag (defense in depth on top of the regex above). + await execFileAsync("git", ["rev-parse", "--verify", "--", branch], { cwd: source }); await execFileAsync("git", ["worktree", "add", "--detach", worktreePath, branch], { cwd: source }); const { stdout: commitHash } = await execFileAsync("git", ["rev-parse", "--short", "HEAD"], { cwd: worktreePath }); respond({ path: worktreePath, branch, source, commit: commitHash.trim() }); diff --git a/src/export-import.ts b/src/export-import.ts index 8ffa57dd..402887d9 100644 --- a/src/export-import.ts +++ b/src/export-import.ts @@ -7,7 +7,7 @@ import { readdirSync, readFileSync, } from "node:fs"; -import { join, basename, resolve } from "node:path"; +import { join, basename, resolve, sep as pathSep } from "node:path"; import { homedir } from "node:os"; const MINIMAL_FILES = ["fleet.yaml", ".env", "scheduler.db"]; @@ -20,6 +20,9 @@ const RUNTIME_EXCLUDES = [ "node_modules", ]; +// Reject tarballs above this size to blunt zip-bomb style exhaustion. +const MAX_IMPORT_BYTES = 500 * 1024 * 1024; + export async function exportConfig( dataDir: string, outputPath: string | undefined, @@ -76,8 +79,56 @@ export async function importConfig( process.exit(1); } + // Size guard (zip-bomb style). + const inputSize = statSync(absFile).size; + if (inputSize > MAX_IMPORT_BYTES) { + console.error(`Import file exceeds ${MAX_IMPORT_BYTES} bytes: ${inputSize}`); + process.exit(1); + } + mkdirSync(dataDir, { recursive: true }); + // Zip-slip / absolute-path protection: list every entry in the archive and + // verify the resolved path stays under dataDir's parent. Reject absolute + // paths, `..` segments and entries that escape after path resolution. + const extractRoot = resolve(join(dataDir, "..")); + const expectedPrefix = resolve(dataDir) + pathSep; + const expectedBase = basename(resolve(dataDir)); + let entries: string[]; + try { + const { stdout } = { + stdout: execFileSync("tar", ["tzf", absFile], { stdio: ["ignore", "pipe", "pipe"] }), + }; + entries = stdout + .toString("utf8") + .split("\n") + .map((s) => s.trim()) + .filter((s) => s.length > 0); + } catch (err) { + console.error(`Failed to list archive contents: ${(err as Error).message}`); + process.exit(1); + } + for (const entry of entries) { + if (entry.startsWith("/") || entry.includes("..")) { + console.error(`Refusing to import archive with unsafe entry: ${entry}`); + process.exit(1); + } + const dest = resolve(extractRoot, entry); + // Each entry must land inside the target dataDir (same basename as export). + // Allow the dataDir itself and anything beneath it. + if (dest !== resolve(dataDir) && !dest.startsWith(expectedPrefix)) { + console.error(`Refusing to import entry outside dataDir: ${entry}`); + process.exit(1); + } + // First path component must match dataDir's basename (tar strips nothing + // here — we rely on the export producing `basename(dataDir)/...`). + const firstSeg = entry.split(/[\\/]/, 1)[0]; + if (firstSeg !== expectedBase) { + console.error(`Refusing to import entry with unexpected root: ${entry}`); + process.exit(1); + } + } + // Backup existing config files const timestamp = Date.now(); for (const name of ["fleet.yaml", ".env"]) { @@ -89,8 +140,11 @@ export async function importConfig( } } - // Extract — strip the top-level directory name - execFileSync("tar", ["xzf", absFile, "-C", join(dataDir, "..")], { stdio: "pipe" }); + // Extract — strip the top-level directory name. + // `-P` is intentionally NOT used: we want tar's default behaviour of + // rejecting absolute paths. The per-entry audit above already caught any + // absolute or traversal entries. + execFileSync("tar", ["xzf", absFile, "-C", extractRoot], { stdio: "pipe" }); console.log(`Imported to: ${dataDir}`); // Validate paths in fleet.yaml diff --git a/src/fleet-context.ts b/src/fleet-context.ts index 1e35ec79..e6ce996e 100644 --- a/src/fleet-context.ts +++ b/src/fleet-context.ts @@ -7,7 +7,8 @@ import type { CostGuard } from "./cost-guard.js"; export type RouteTarget = | { kind: "instance"; name: string } - | { kind: "general"; name: string }; + | { kind: "general"; name: string } + | { kind: "classic"; name: string }; export interface SysInfo { uptime_seconds: number; @@ -27,6 +28,7 @@ export function isProbeableRouteTarget(target: RouteTarget): boolean { */ export interface FleetContext { readonly adapter: ChannelAdapter | null; + readonly adapters?: Map; readonly fleetConfig: FleetConfig | null; readonly routingTable: Map; readonly instanceIpcClients: Map; @@ -42,6 +44,6 @@ export interface FleetContext { connectIpcToInstance(name: string): Promise; saveFleetConfig(): void; getInstanceDir(name: string): string; - createForumTopic(topicName: string): Promise; + createForumTopic(topicName: string, adapterId?: string): Promise; removeInstance(name: string): Promise; } diff --git a/src/fleet-manager.ts b/src/fleet-manager.ts index 318b561d..4426f476 100644 --- a/src/fleet-manager.ts +++ b/src/fleet-manager.ts @@ -1,18 +1,20 @@ -import { existsSync, readFileSync, mkdirSync, writeFileSync, unlinkSync, rmSync, readdirSync } from "node:fs"; +import { existsSync, readFileSync, mkdirSync, writeFileSync, unlinkSync, rmSync, readdirSync, renameSync, copyFileSync, chmodSync } from "node:fs"; import { randomBytes } from "node:crypto"; import { access } from "node:fs/promises"; import { createServer, type Server } from "node:http"; import { join, dirname, basename } from "node:path"; import { fileURLToPath } from "node:url"; -import { getAgendHome } from "./paths.js"; +import { getAgendHome, ensureWorkspaceGit } from "./paths.js"; +import { sdNotify } from "./sd-notify.js"; import yaml from "js-yaml"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); -import type { FleetConfig, InstanceConfig, CostGuardConfig, DailySummaryConfig, WebhookConfig } from "./types.js"; +import type { FleetConfig, InstanceConfig, ChannelConfig, CostGuardConfig, DailySummaryConfig, WebhookConfig } from "./types.js"; import { isProbeableRouteTarget, type RouteTarget } from "./fleet-context.js"; import { loadFleetConfig, DEFAULT_COST_GUARD, DEFAULT_DAILY_SUMMARY, DEFAULT_INSTANCE_CONFIG } from "./config.js"; import { EventLog } from "./event-log.js"; +import { AdapterWorld } from "./adapter-world.js"; import { CostGuard, formatCents } from "./cost-guard.js"; import { TmuxManager } from "./tmux-manager.js"; import { AccessManager } from "./channel/access-manager.js"; @@ -37,8 +39,9 @@ import { InstanceLifecycle, type LifecycleContext } from "./instance-lifecycle.j import { TopicArchiver, type ArchiverContext } from "./topic-archiver.js"; import { StatuslineWatcher, type StatuslineWatcherContext } from "./statusline-watcher.js"; import { outboundHandlers, type OutboundContext } from "./outbound-handlers.js"; -import { handleWebRequest } from "./web-api.js"; +import { handleWebRequest, broadcastSseEvent } from "./web-api.js"; import { handleAgentRequest, type AgentEndpointContext } from "./agent-endpoint.js"; +import { ClassicChannelManager, classicInstanceName } from "./classic-channel-manager.js"; import { getTmuxSession } from "./config.js"; @@ -62,6 +65,14 @@ export class FleetManager implements FleetContext, LifecycleContext, ArchiverCon get daemons() { return this.lifecycle.daemons; } fleetConfig: FleetConfig | null = null; adapter: ChannelAdapter | null = null; + readonly worlds = new Map(); + readonly adapters: Map = new Map(); // derived view for backward compat + /** Track which world each instance is bound to */ + private instanceWorldBinding = new Map(); + private accessManager: AccessManager | null = null; + + /** Primary world (first adapter) — used for fleet-level notifications */ + get primaryWorld(): AdapterWorld | undefined { return this.worlds.values().next().value as AdapterWorld | undefined; } readonly routing = new RoutingEngine(); get routingTable(): Map { return this.routing.map; } instanceIpcClients: Map = new Map(); @@ -84,12 +95,21 @@ export class FleetManager implements FleetContext, LifecycleContext, ArchiverCon private topicArchiver: TopicArchiver; controlClient: TmuxControlClient | null = null; + classicChannels: ClassicChannelManager | null = null; // Model failover state private failoverActive = new Map(); // instance → current failover model + // IPC reconnect: tracks instances being intentionally stopped (skip reconnect) + readonly ipcStoppingInstances = new Set(); + + // Adapter restart: prevents re-entrant restart attempts + private adapterRestarting = new Set(); + // Health endpoint private healthServer: Server | null = null; + private updateCheckTimer: ReturnType | ReturnType | null = null; + private watchdogTimer: ReturnType | null = null; private startedAt = 0; // Mirror topic: buffer cross-instance messages, flush every 3s @@ -147,14 +167,67 @@ export class FleetManager implements FleetContext, LifecycleContext, ArchiverCon buildRoutingTable(): Map { if (this.fleetConfig) { this.routing.rebuild(this.fleetConfig); + this.reregisterClassicChannels(); } return this.routing.map; } + /** Re-register classic channels after routing rebuild (rebuild clears the table) */ + private reregisterClassicChannels(): void { + if (!this.classicChannels) return; + const channels = this.classicChannels.getAll(); + for (const ch of channels) { + this.routing.register(ch.channelId, { kind: "classic", name: ch.instanceName }); + } + // Always update adapter openChannels (including empty — clears stale entries on /stop) + for (const [, w] of this.worlds) { + if (typeof (w.adapter as any)?.setOpenChannels === "function") { + (w.adapter as any).setOpenChannels(channels.map(ch => ch.channelId)); + } + } + if (channels.length > 0) { + this.logger.info({ count: channels.length }, "Registered classic channel routes"); + } + } + getInstanceDir(name: string): string { return join(this.dataDir, "instances", name); } + /** Get the adapter bound to an instance, falling back to primary adapter */ + getAdapterForInstance(name: string): ChannelAdapter | null { + const worldId = this.instanceWorldBinding.get(name); + if (worldId) return this.worlds.get(worldId)?.adapter ?? this.adapter; + return this.adapter; + } + + /** Get the world for an instance */ + getWorldForInstance(name: string): AdapterWorld | undefined { + const worldId = this.instanceWorldBinding.get(name); + return worldId ? this.worlds.get(worldId) : (this.worlds.values().next().value as AdapterWorld | undefined); + } + + /** Get channel config for a specific adapter (by id), falling back to primary */ + getChannelConfig(adapterId?: string): import("./types.js").ChannelConfig | undefined { + if (adapterId) { + const world = this.worlds.get(adapterId); + if (world) return world.channelConfig; + } + return this.fleetConfig?.channel; + } + + /** Get the group_id for an instance's bound adapter */ + getGroupIdForInstance(name: string): string { + const world = this.getWorldForInstance(name); + return world?.groupId ?? String(this.fleetConfig?.channel?.group_id ?? ""); + } + + /** Bind an instance to a specific world. fromInbound=true skips general_topic to prevent overwrite. */ + bindInstanceAdapter(name: string, adapterId: string, fromInbound = false): void { + if (fromInbound && this.fleetConfig?.instances[name]?.general_topic) return; + this.instanceWorldBinding.set(name, adapterId); + } + getInstanceStatus(name: string): "running" | "stopped" | "crashed" { const pidPath = join(this.getInstanceDir(name), "daemon.pid"); if (!existsSync(pidPath)) return "stopped"; @@ -187,8 +260,8 @@ export class FleetManager implements FleetContext, LifecycleContext, ArchiverCon topicMode: boolean, ): Promise { const raw = this.fleetConfig?.defaults?.startup; - const concurrency = Math.max(1, Math.min(20, raw?.concurrency ?? 3)); - const staggerMs = Math.max(0, Math.min(30_000, raw?.stagger_delay_ms ?? 2000)); + const concurrency = Math.max(1, Math.min(20, raw?.concurrency ?? 10)); + const staggerMs = Math.max(0, Math.min(30_000, raw?.stagger_delay_ms ?? 500)); const byWorkDir = new Map(); for (const [name, config] of entries) { @@ -245,6 +318,7 @@ export class FleetManager implements FleetContext, LifecycleContext, ArchiverCon if (this.configPath) { this.loadConfig(this.configPath); this.routing.rebuild(this.fleetConfig!); + this.reregisterClassicChannels(); } const config = this.fleetConfig?.instances[name]; if (!config) throw new Error(`Instance not found: ${name}`); @@ -276,8 +350,13 @@ export class FleetManager implements FleetContext, LifecycleContext, ArchiverCon async startAll(configPath: string): Promise { this.configPath = configPath; this.loadEnvFile(); + + // Rotate fleet.log if oversized (before any logging) + const { rotateLogIfNeeded } = await import("./logger.js"); + rotateLogIfNeeded(join(this.dataDir, "fleet.log")); + const fleet = this.loadConfig(configPath); - const topicMode = fleet.channel?.mode === "topic"; + const topicMode = fleet.channel?.mode === "topic" || !!fleet.channels?.some(ch => ch.mode === "topic"); // Set tmux socket isolation for custom AGEND_HOME const { getTmuxSocketName: getSocket } = await import("./paths.js"); @@ -307,7 +386,7 @@ export class FleetManager implements FleetContext, LifecycleContext, ArchiverCon // Also kill orphaned windows: any window with a topic ID suffix (name-tNNNNN) // that isn't in the current config — these are leftovers from deleted instances const isKnownInstance = agendNames.has(w.name); - const isOrphanedInstance = !isKnownInstance && /-t\d+$/.test(w.name); + const isOrphanedInstance = !isKnownInstance && (/-t\d+$/.test(w.name) || /^classic-/.test(w.name)); if (isKnownInstance || isOrphanedInstance) { if (isOrphanedInstance) this.logger.info({ window: w.name }, "Cleaning up orphaned tmux window"); const tm = new TmuxManager(getTmuxSession(), w.id); @@ -323,6 +402,39 @@ export class FleetManager implements FleetContext, LifecycleContext, ArchiverCon this.eventLog = new EventLog(join(this.dataDir, "events.db")); + // Initialize classic channel manager and register existing channels in routing + this.classicChannels = new ClassicChannelManager(this.dataDir, this.logger); + for (const ch of this.classicChannels.getAll()) { + this.routing.register(ch.channelId, { kind: "classic", name: ch.instanceName }); + } + + // Poll classicBot.yaml for external changes every 30s + this.classicReloadTimer = setInterval(async () => { + try { + if (!this.classicChannels) return; + const fleetBackend = this.fleetConfig?.defaults?.backend; + const oldBackends = new Map(); + for (const ch of this.classicChannels.getAll()) { + oldBackends.set(ch.instanceName, this.classicChannels.getBackendByInstance(ch.instanceName, fleetBackend)); + } + if (!this.classicChannels.checkReload()) return; + this.reregisterClassicChannels(); + for (const ch of this.classicChannels.getAll()) { + const newBackend = this.classicChannels.getBackendByInstance(ch.instanceName, fleetBackend); + if (this.daemons.has(ch.instanceName) && oldBackends.get(ch.instanceName) !== newBackend) { + this.logger.info({ instanceName: ch.instanceName, from: oldBackends.get(ch.instanceName), to: newBackend }, "Backend changed — restarting"); + await this.stopInstance(ch.instanceName).catch(() => {}); + // Small delay to let tmux window clean up + await new Promise(r => setTimeout(r, 2000)); + await this.startClassicInstance(ch.instanceName, newBackend).catch(err => + this.logger.warn({ err, instanceName: ch.instanceName }, "Failed to restart classic instance")); + } + } + } catch (err) { + this.logger.warn({ err }, "classicBot.yaml reload error"); + } + }, 30_000); + const costGuardConfig: CostGuardConfig = { ...DEFAULT_COST_GUARD, ...fleet.defaults.cost_guard, @@ -356,6 +468,8 @@ export class FleetManager implements FleetContext, LifecycleContext, ArchiverCon if (!this.adapter || !this.fleetConfig?.channel?.group_id) return; this.adapter.sendText(String(this.fleetConfig.channel.group_id), text) .catch(e => this.logger.warn({ err: e }, "Failed to send daily summary")); + // Rotate classic channel chat logs daily + this.classicChannels?.rotateLogs(); }, () => { const instances = Object.keys(this.fleetConfig?.instances ?? {}); const costMap = new Map(); @@ -371,24 +485,84 @@ export class FleetManager implements FleetContext, LifecycleContext, ArchiverCon }); this.dailySummary.start(); - // Auto-create general instance if none configured - const hasGeneralTopic = Object.values(fleet.instances).some(inst => inst.general_topic === true); - if (!hasGeneralTopic) { - this.logger.info("Auto-creating general instance for General Topic"); - const generalDir = join(getAgendHome(), "general"); + // Rotate classic channel chat logs daily (piggyback on daily summary timer) + this.classicChannels?.rotateLogs(); + + // Auto-create general instance(s) — one per adapter that lacks a general + const channelConfigs = fleet.channels ?? (fleet.channel ? [fleet.channel] : []); + const generalInstances = Object.entries(fleet.instances).filter(([, inst]) => inst.general_topic === true); + let generalsCreated = false; + + // Collect unbound generals (no channel_id set) for auto-assignment + const unboundGenerals = generalInstances.filter(([, inst]) => !inst.channel_id); + // Track which adapters still need a general + const needsGeneral: Array<{ adapterId: string; ch: typeof channelConfigs[0] }> = []; + + for (const ch of channelConfigs) { + const adapterId = ch.id ?? ch.type; + // Check if any general is explicitly bound to this adapter + if (generalInstances.some(([, inst]) => inst.channel_id === adapterId)) continue; + // Check if any general matches by name heuristic + if (generalInstances.some(([name]) => name.includes(adapterId))) continue; + // For single-channel setups, accept any general + if (channelConfigs.length === 1 && generalInstances.length > 0) continue; + needsGeneral.push({ adapterId, ch }); + } + + // Phase 1: Adopt unbound generals by topic_id match (most accurate) + for (const need of [...needsGeneral]) { + const matchIdx = unboundGenerals.findIndex(([, inst]) => { + const topicId = String(inst.topic_id ?? ""); + if (need.ch.type === "discord" && need.ch.options?.general_channel_id) { + return topicId === String(need.ch.options.general_channel_id); + } + if (need.ch.type === "telegram") { + return topicId === "1" || topicId === ""; + } + return false; + }); + if (matchIdx >= 0) { + const [[unboundName, unboundInst]] = unboundGenerals.splice(matchIdx, 1); + unboundInst.channel_id = need.adapterId; + this.logger.info({ adapter: need.adapterId, name: unboundName }, "Bound existing general to adapter (topic_id match)"); + needsGeneral.splice(needsGeneral.indexOf(need), 1); + generalsCreated = true; + } + } + + // Phase 2: Adopt remaining unbound generals (first-come) + for (const need of [...needsGeneral]) { + if (unboundGenerals.length > 0) { + const [[unboundName, unboundInst]] = unboundGenerals.splice(0, 1); + unboundInst.channel_id = need.adapterId; + this.logger.info({ adapter: need.adapterId, name: unboundName }, "Bound existing general to adapter"); + needsGeneral.splice(needsGeneral.indexOf(need), 1); + generalsCreated = true; + continue; + } + break; + } + + // Phase 3: Create new generals for any remaining adapters + for (const need of needsGeneral) { + const name = channelConfigs.length > 1 ? `general-${need.adapterId}` : "general"; + if (fleet.instances[name]) continue; + this.logger.warn({ adapter: need.adapterId, name }, "No general instance for adapter — auto-creating"); + const generalDir = join(getAgendHome(), name); mkdirSync(generalDir, { recursive: true }); const backendName = fleet.defaults.backend ?? "claude-code"; this.ensureGeneralInstructions(generalDir, backendName); - const generalConfig: InstanceConfig = { + fleet.instances[name] = { ...DEFAULT_INSTANCE_CONFIG, working_directory: generalDir, general_topic: true, + channel_id: need.adapterId, }; - fleet.instances["general"] = generalConfig; - this.saveFleetConfig(); + generalsCreated = true; } + if (generalsCreated) this.saveFleetConfig(); - if (topicMode && fleet.channel) { + if (topicMode && (fleet.channel || fleet.channels?.length)) { const schedulerConfig: SchedulerConfig = { ...DEFAULT_SCHEDULER_CONFIG, ...this.fleetConfig?.defaults.scheduler, @@ -398,7 +572,7 @@ export class FleetManager implements FleetContext, LifecycleContext, ArchiverCon join(this.dataDir, "scheduler.db"), (schedule) => this.handleScheduleTrigger(schedule), schedulerConfig, - (name) => this.fleetConfig?.instances?.[name] != null, + (name) => this.fleetConfig?.instances?.[name] != null || !!this.classicChannels?.getAll().some(ch => ch.instanceName === name), ); this.scheduler.init(); this.logger.info("Scheduler initialized"); @@ -418,30 +592,93 @@ export class FleetManager implements FleetContext, LifecycleContext, ArchiverCon } } - await this.startInstancesWithConcurrency(Object.entries(fleet.instances), topicMode); + // Phase 1: Start general instances first and wait for them + const allEntries = Object.entries(fleet.instances); + const generals = allEntries.filter(([_, cfg]) => cfg.general_topic); + const others = allEntries.filter(([_, cfg]) => !cfg.general_topic); - if (topicMode && fleet.channel) { + if (generals.length > 0) { + for (const [name, cfg] of generals) { + try { + await this.startInstance(name, cfg, topicMode); + } catch (err) { + this.logger.error({ err, name }, "Failed to start general instance"); + const errorMsg = err instanceof Error ? err.message : String(err); + const topicId = cfg.topic_id ? String(cfg.topic_id) : undefined; + if (this.adapter && topicId) { + const chatId = this.adapter.getChatId?.() ?? ""; + if (chatId) { + this.adapter.sendText(chatId, `⚠️ General instance "${name}" failed to start:\n${errorMsg}`, { threadId: topicId }).catch(() => {}); + } + } + } + } + } + + // Signal systemd: generals ready + sdNotify("READY=1"); + this.watchdogTimer = setInterval(() => sdNotify("WATCHDOG=1"), 30_000); - await this.startSharedAdapter(fleet); + // Phase 2: Start remaining instances with staggered concurrency + if (others.length > 0) { + await this.startInstancesWithConcurrency(others, topicMode); + } + + if (topicMode && (fleet.channel || fleet.channels?.length)) { + + try { + await this.startSharedAdapter(fleet); + } catch (err) { + this.logger.error({ err }, "startSharedAdapter failed — fleet continues without some adapters"); + } + + // Pre-bind general instances to their corresponding adapter + for (const [name, config] of Object.entries(fleet.instances)) { + if (!config.general_topic) continue; + const channelConfigs = fleet.channels ?? (fleet.channel ? [fleet.channel] : []); + for (const ch of channelConfigs) { + const id = ch.id ?? ch.type; + if (name.includes(id)) { this.bindInstanceAdapter(name, id); break; } + } + } // Auto-create topics AFTER adapter is ready (needs adapter.createTopic) await this.topicCommands.autoCreateTopics(); const routeSummary = this.routing.rebuild(this.fleetConfig!); + this.reregisterClassicChannels(); this.logger.info(`Routes: ${routeSummary}`); // Resolve topic icon emoji IDs and start idle archive poller await this.resolveTopicIcons(); this.topicArchiver.startPoller(); - await new Promise(r => setTimeout(r, 3000)); - await this.connectToInstances(fleet); + // IPC is already wired by startInstancesWithConcurrency → startInstance → + // connectIpcToInstance. The previous 3s sleep + connectToInstances loop + // was redundant. + + // Start classic channel instances (parallel, concurrency 3) + if (this.classicChannels) { + const fleetBackend = this.fleetConfig?.defaults?.backend; + const channels = this.classicChannels.getAll(); + const concurrency = 3; + let idx = 0; + while (idx < channels.length) { + const batch = channels.slice(idx, idx + concurrency); + await Promise.allSettled(batch.map(ch => + this.startClassicInstance(ch.instanceName, this.classicChannels!.getBackendByInstance(ch.instanceName, fleetBackend)).catch(err => + this.logger.warn({ err, instanceName: ch.instanceName }, "Failed to start classic instance")) + )); + idx += concurrency; + } + } for (const name of Object.keys(fleet.instances)) { this.startStatuslineWatcher(name); } // Notify General topic that fleet is up - const total = Object.keys(fleet.instances).length; + const classicCount = this.classicChannels?.getAll().length ?? 0; + const total = Object.keys(fleet.instances).length + classicCount; const started = this.daemons.size; const failedNames = Object.keys(fleet.instances).filter(n => !this.daemons.has(n)); const generalName = this.findGeneralInstance(); @@ -459,6 +696,12 @@ export class FleetManager implements FleetContext, LifecycleContext, ArchiverCon // Health HTTP endpoint this.startHealthServer(fleet.health_port ?? 19280); + // Daily update check — first check after 1 hour, then every 24 hours + this.updateCheckTimer = setTimeout(() => { + this.checkForUpdates(); + this.updateCheckTimer = setInterval(() => this.checkForUpdates(), 24 * 60 * 60 * 1000); + }, 60 * 60 * 1000); + // SIGHUP: hot-reload instance config (add/remove/restart instances) const onSighup = () => { this.logger.info("Received SIGHUP, hot-reloading config..."); @@ -492,9 +735,22 @@ export class FleetManager implements FleetContext, LifecycleContext, ArchiverCon process.once("SIGUSR1", onFullRestart); } - /** Start the shared channel adapter for topic mode */ + /** Start the shared channel adapter(s) for topic mode */ private async startSharedAdapter(fleet: FleetConfig): Promise { - const channelConfig = fleet.channel!; + const channelConfigs = fleet.channels ?? (fleet.channel ? [fleet.channel] : []); + if (channelConfigs.length === 0) return; + + // Start primary adapter (first channel) — this.adapter for backward compat + await this.startSingleAdapter(fleet, channelConfigs[0]); + + // Start additional adapters + for (let i = 1; i < channelConfigs.length; i++) { + await this.startAdditionalAdapter(channelConfigs[i]); + } + } + + /** Start the primary adapter (backward-compatible, sets this.adapter) */ + private async startSingleAdapter(fleet: FleetConfig, channelConfig: ChannelConfig): Promise { const botToken = process.env[channelConfig.bot_token_env]; if (!botToken) { this.logger.warn({ env: channelConfig.bot_token_env }, "Bot token env not set, skipping shared adapter"); @@ -507,15 +763,20 @@ export class FleetManager implements FleetContext, LifecycleContext, ArchiverCon channelConfig.access, join(accessDir, "access.json"), ); + this.accessManager = accessManager; const inboxDir = join(this.dataDir, "inbox"); mkdirSync(inboxDir, { recursive: true }); + const adapterId = channelConfig.id ?? channelConfig.type; this.adapter = await createAdapter(channelConfig, { - id: "fleet", + id: adapterId, botToken, accessManager, inboxDir, }); + const world = new AdapterWorld(adapterId, this.adapter, accessManager, channelConfig); + this.worlds.set(adapterId, world); + (this.adapters as Map).set(adapterId, this.adapter); this.adapter.on("message", safeHandler(async (msg: InboundMessage) => { await this.handleInboundMessage(msg); @@ -548,14 +809,145 @@ export class FleetManager implements FleetContext, LifecycleContext, ArchiverCon await this.topicCommands.handleTopicDeleted(data.threadId); }, this.logger, "adapter.topic_closed")); - await this.topicCommands.registerBotCommands(); - await this.adapter.start(); - if (fleet.channel?.group_id) { - this.adapter.setChatId(String(fleet.channel.group_id)); - } + // Handle classic bot slash commands (/start, /stop, /chat, /compact, /save, /load) + this.adapter.on("slash_command", safeHandler(async (data: { command: string; channelId: string; channelName: string; guildId?: string; userId: string; username?: string; text?: string; options?: Record; respond: (text: string) => Promise }) => { + if (data.command === "start") { + const reply = await this.handleClassicStart(data.channelId, data.channelName, data.userId, data.guildId); + await data.respond(reply); + } else if (data.command === "stop") { + const reply = await this.handleClassicStop(data.channelId); + await data.respond(reply); + } else if (data.command === "chat") { + const text = data.text ?? ""; + if (!text) { await data.respond("Usage: `/chat `"); return; } + const target = this.routing.resolve(data.channelId); + if (!target || target.kind !== "classic") { + await data.respond("No active agent in this channel. Use `/start` first."); + return; + } + const replyMsgId = await data.respond("👀"); + const username = data.username ?? data.userId; + ClassicChannelManager.logMessage(target.name, username, `/chat ${text}`, new Date()); + await this.forwardToClassicInstance(target.name, text, { + chatId: data.channelId, + threadId: data.channelId, + messageId: replyMsgId ?? "", + userId: data.userId, + username, + source: "discord", + timestamp: new Date(), + }); + } else if (data.command === "compact" || data.command === "save" || data.command === "load") { + if (!this.classicChannels?.isAdmin(data.userId)) { + await data.respond("⛔ This command requires admin access."); + return; + } + const target = this.routing.resolve(data.channelId); + if (!target || target.kind !== "classic") { + await data.respond("No active agent in this channel. Use `/start` first."); + return; + } + let rawCmd: string; + if (data.command === "compact") { + rawCmd = "/compact"; + } else if (data.command === "save") { + const filename = data.options?.filename as string; + if (!/^[\w.-]+$/.test(filename)) { await data.respond("⛔ Invalid filename — only letters, numbers, dots, hyphens, underscores allowed."); return; } + rawCmd = data.options?.force ? `/chat save ${filename} -f` : `/chat save ${filename}`; + } else { + const filename = data.options?.filename as string; + if (!/^[\w.-]+$/.test(filename)) { await data.respond("⛔ Invalid filename — only letters, numbers, dots, hyphens, underscores allowed."); return; } + rawCmd = `/chat load ${filename}`; + } + this.pasteRawToClassicInstance(target.name, rawCmd); + await data.respond(`✅ Sent \`${rawCmd}\` to ${target.name}`); + } else if (data.command === "ctx") { + const target = this.routing.resolve(data.channelId); + if (!target) { + await data.respond("No active agent in this channel."); + return; + } + const instanceName = target.name; + const backend = target.kind === "classic" + ? (this.classicChannels?.getBackendByInstance(instanceName, this.fleetConfig?.defaults?.backend) ?? "claude-code") + : (this.fleetConfig?.instances[instanceName]?.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code"); + let context: number | null = null; + // Try statusline.json first + try { + const statusFile = join(this.getInstanceDir(instanceName), "statusline.json"); + if (existsSync(statusFile)) { + const d = JSON.parse(readFileSync(statusFile, "utf-8")); + context = d.context_window?.used_percentage ?? null; + } + } catch { /* ignore */ } + // Fallback: capture tmux pane + if (context == null) { + try { + const { execFileSync } = await import("node:child_process"); + const { getTmuxSocketName } = await import("./paths.js"); + const socketName = getTmuxSocketName(); + const tmuxArgs = socketName + ? ["-L", socketName, "capture-pane", "-t", `${getTmuxSession()}:${instanceName}`, "-p"] + : ["capture-pane", "-t", `${getTmuxSession()}:${instanceName}`, "-p"]; + const pane = execFileSync("tmux", tmuxArgs, + { encoding: "utf-8", timeout: 2000, stdio: ["pipe", "pipe", "pipe"] }); + const m = pane.match(/(\d+)%.*[!❯>]/m) || pane.match(/◔\s*(\d+)%/) || pane.match(/\[(\d+)%\]/); + if (m) context = parseInt(m[1], 10); + } catch { /* ignore */ } + } + if (context != null) { + await data.respond(`📊 Context: ${context}% used\nBackend: ${backend}\nInstance: ${instanceName}`); + } else { + await data.respond(`Context info not available yet.\nBackend: ${backend}\nInstance: ${instanceName}`); + } + } else if (data.command === "collab") { + if (!this.classicChannels?.isAdmin(data.userId)) { + await data.respond("⛔ This command requires admin access."); + return; + } + if (!this.classicChannels.isClassicChannel(data.channelId)) { + await data.respond("No active agent in this channel. Use `/start` first."); + return; + } + const newState = this.classicChannels.toggleCollab(data.channelId); + await data.respond(newState + ? "🤝 Collaboration mode **ON** — @mention this bot to trigger the agent. Other bot messages are visible." + : "💬 Collaboration mode **OFF** — use `/chat` to talk to the agent."); + } else if (data.command === "update") { + if (!this.classicChannels?.isAdmin(data.userId)) { + await data.respond("⛔ This command requires admin access."); + return; + } + await data.respond("📦 Updating AgEnD... Use `agend update` from CLI for full control."); + } else if (data.command === "doctor") { + if (!this.classicChannels?.isAdmin(data.userId)) { + await data.respond("⛔ This command requires admin access."); + return; + } + try { + const { execSync } = await import("node:child_process"); + const backend = this.fleetConfig?.defaults?.backend || "claude-code"; + const result = execSync(`agend backend doctor ${backend}`, { timeout: 30_000, encoding: "utf-8" }); + const clean = result.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, ""); + await data.respond(clean || "No output"); + } catch (err: any) { + const output = (err.stdout ?? err.message ?? "Doctor failed").replace(/\x1b\[[0-9;]*[a-zA-Z]/g, ""); + await data.respond(output); + } + } + }, this.logger, "adapter.slash_command")); + + await this.topicCommands.registerBotCommands().catch(e => + this.logger.warn({ err: e }, "registerBotCommands failed (non-fatal)")); - this.adapter.on("started", safeHandler((username: string) => { + this.adapter.on("started", safeHandler((username: string, userId?: string) => { this.logger.info(`Bot @${username} polling started. Ensure no other service is polling this bot token.`); + const w = this.worlds.values().next().value as AdapterWorld | undefined; + if (w) { + w.botUsername = username; + if (userId) w.botUserId = userId; + } + if (userId) this.botUserId = userId; }, this.logger, "adapter.started")); this.adapter.on("polling_conflict", safeHandler(({ attempt, delay }: { attempt: number; delay: number }) => { this.logger.warn(`409 Conflict (attempt ${attempt}), retry in ${delay / 1000}s`); @@ -563,6 +955,22 @@ export class FleetManager implements FleetContext, LifecycleContext, ArchiverCon this.adapter.on("handler_error", safeHandler((err: unknown) => { this.logger.warn({ err: err instanceof Error ? err.message : String(err) }, "Adapter handler error"); }, this.logger, "adapter.handler_error")); + this.adapter.on("error", (err: unknown) => { + this.logger.error({ err }, "Primary adapter fatal error"); + this.restartAdapter(this.adapter!, "primary").catch(() => {}); + }); + + this.adapter.on("new_group_detected", safeHandler((data: { groupId: string; groupTitle: string; source: string }) => { + const adminMsg = `🆕 Bot added to new server:\n• Name: ${data.groupTitle}\n• ID: ${data.groupId}\n• Platform: ${data.source}\n\nTo allow: add \`${data.groupId}\` to classicBot.yaml \`allowed_guilds\``; + const generalId = this.findGeneralInstance(); + if (generalId) this.notifyInstanceTopic(generalId, adminMsg); + }, this.logger, "adapter.new_group_detected")); + + // Start adapter AFTER all event listeners are registered (started event sets botUsername) + await this.adapter.start(); + if (fleet.channel?.group_id) { + this.adapter.setChatId(String(fleet.channel.group_id)); + } this.startTopicCleanupPoller(); @@ -573,11 +981,197 @@ export class FleetManager implements FleetContext, LifecycleContext, ArchiverCon }, 5 * 60 * 1000); } - /** Connect IPC clients to each daemon instance's channel.sock */ - private async connectToInstances(fleet: FleetConfig): Promise { - for (const name of Object.keys(fleet.instances)) { - await this.connectIpcToInstance(name); + /** Start an additional (non-primary) adapter */ + private async startAdditionalAdapter(channelConfig: ChannelConfig): Promise { + const adapterId = channelConfig.id ?? channelConfig.type; + const botToken = process.env[channelConfig.bot_token_env]; + if (!botToken) { + this.logger.warn({ env: channelConfig.bot_token_env, adapterId }, "Bot token env not set, skipping adapter"); + return; + } + + const accessDir = join(this.dataDir, "access"); + mkdirSync(accessDir, { recursive: true }); + const accessManager = new AccessManager( + channelConfig.access, + join(accessDir, `access-${adapterId}.json`), + ); + const inboxDir = join(this.dataDir, "inbox"); + mkdirSync(inboxDir, { recursive: true }); + + const adapter = await createAdapter(channelConfig, { + id: adapterId, + botToken, + accessManager, + inboxDir, + }); + const world = new AdapterWorld(adapterId, adapter, accessManager, channelConfig); + this.worlds.set(adapterId, world); + (this.adapters as Map).set(adapterId, adapter); + + // Wire up event handlers (same as primary, routes through shared handleInboundMessage) + adapter.on("message", safeHandler(async (msg: InboundMessage) => { + await this.handleInboundMessage(msg); + }, this.logger, `adapter[${adapterId}].message`)); + + adapter.on("callback_query", safeHandler(async (data: { callbackData: string; chatId: string; threadId?: string; messageId: string }) => { + if (data.callbackData.startsWith("hang:")) { + const parts = data.callbackData.split(":"); + const action = parts[1]; + const instanceName = parts[2]; + if (action === "restart") { + await this.stopInstance(instanceName); + const config = this.fleetConfig?.instances[instanceName]; + if (config) { + const topicMode = this.fleetConfig?.channel?.mode === "topic"; + await this.startInstance(instanceName, config, topicMode); + } + adapter.editMessage(data.chatId, data.messageId, `🔄 ${instanceName} restarted.`).catch(() => {}); + } else { + adapter.editMessage(data.chatId, data.messageId, `⏳ Continuing to wait for ${instanceName}.`).catch(() => {}); + } + } + }, this.logger, `adapter[${adapterId}].callback_query`)); + + adapter.on("topic_closed", safeHandler(async (data: { chatId: string; threadId: string }) => { + if (this.topicArchiver.isArchived(data.threadId)) return; + await this.topicCommands.handleTopicDeleted(data.threadId); + }, this.logger, `adapter[${adapterId}].topic_closed`)); + + // Slash commands: classic bot + admin commands + adapter.on("slash_command", safeHandler(async (data: { command: string; channelId: string; channelName: string; guildId?: string; userId: string; username?: string; text?: string; options?: Record; respond: (text: string) => Promise }) => { + if (data.command === "start") { + const reply = await this.handleClassicStart(data.channelId, data.channelName, data.userId, data.guildId); + await data.respond(reply); + } else if (data.command === "stop") { + const reply = await this.handleClassicStop(data.channelId); + await data.respond(reply); + } else if (data.command === "chat") { + const text = data.text ?? ""; + if (!text) { await data.respond("Usage: `/chat `"); return; } + const target = this.routing.resolve(data.channelId); + if (!target || target.kind !== "classic") { + await data.respond("No active agent in this channel. Use `/start` first."); + return; + } + const replyMsgId = await data.respond("👀"); + const username = data.username ?? data.userId; + ClassicChannelManager.logMessage(target.name, username, `/chat ${text}`, new Date()); + await this.forwardToClassicInstance(target.name, text, { + chatId: data.channelId, + threadId: data.channelId, + messageId: replyMsgId ?? "", + userId: data.userId, + username, + source: channelConfig.type, + timestamp: new Date(), + }); + } else if (data.command === "compact" || data.command === "save" || data.command === "load") { + if (!this.classicChannels?.isAdmin(data.userId)) { + await data.respond("⛔ This command requires admin access."); + return; + } + const target = this.routing.resolve(data.channelId); + if (!target || target.kind !== "classic") { + await data.respond("No active agent in this channel. Use `/start` first."); + return; + } + let rawCmd: string; + if (data.command === "compact") { + rawCmd = "/compact"; + } else if (data.command === "save") { + const filename = data.options?.filename as string; + if (!/^[\w.-]+$/.test(filename)) { await data.respond("⛔ Invalid filename — only letters, numbers, dots, hyphens, underscores allowed."); return; } + rawCmd = data.options?.force ? `/chat save ${filename} -f` : `/chat save ${filename}`; + } else { + const filename = data.options?.filename as string; + if (!/^[\w.-]+$/.test(filename)) { await data.respond("⛔ Invalid filename — only letters, numbers, dots, hyphens, underscores allowed."); return; } + rawCmd = `/chat load ${filename}`; + } + this.pasteRawToClassicInstance(target.name, rawCmd); + await data.respond(`✅ Sent \`${rawCmd}\` to ${target.name}`); + } else if (data.command === "ctx") { + const target = this.routing.resolve(data.channelId); + if (!target) { await data.respond("No active agent in this channel."); return; } + const instanceName = target.name; + const ctxBackend = target.kind === "classic" + ? (this.classicChannels?.getBackendByInstance(instanceName, this.fleetConfig?.defaults?.backend) ?? "claude-code") + : (this.fleetConfig?.instances[instanceName]?.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code"); + let context: number | null = null; + try { + const statusFile = join(this.getInstanceDir(instanceName), "statusline.json"); + if (existsSync(statusFile)) { + const d = JSON.parse(readFileSync(statusFile, "utf-8")); + context = d.context_window?.used_percentage ?? null; + } + } catch { /* ignore */ } + if (context != null) { + await data.respond(`📊 Context: ${context}% used\nBackend: ${ctxBackend}\nInstance: ${instanceName}`); + } else { + await data.respond(`Context info not available yet.\nBackend: ${ctxBackend}\nInstance: ${instanceName}`); + } + } else if (data.command === "collab") { + if (!this.classicChannels?.isAdmin(data.userId)) { + await data.respond("⛔ This command requires admin access."); + return; + } + if (!this.classicChannels.isClassicChannel(data.channelId)) { + await data.respond("No active agent in this channel. Use `/start` first."); + return; + } + const newState = this.classicChannels.toggleCollab(data.channelId); + await data.respond(newState + ? "🤝 Collaboration mode **ON** — @mention this bot to trigger the agent. Other bot messages are visible." + : "💬 Collaboration mode **OFF** — use `/chat` to talk to the agent."); + } else if (data.command === "update") { + if (!this.classicChannels?.isAdmin(data.userId)) { + await data.respond("⛔ This command requires admin access."); + return; + } + await data.respond("📦 Updating AgEnD... Use `agend update` from CLI for full control."); + } else if (data.command === "doctor") { + if (!this.classicChannels?.isAdmin(data.userId)) { + await data.respond("⛔ This command requires admin access."); + return; + } + try { + const { execSync } = await import("node:child_process"); + const backend = this.fleetConfig?.defaults?.backend || "claude-code"; + const result = execSync(`agend backend doctor ${backend}`, { timeout: 30_000, encoding: "utf-8" }); + const clean = result.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, ""); + await data.respond(clean || "No output"); + } catch (err: any) { + const output = (err.stdout ?? err.message ?? "Doctor failed").replace(/\x1b\[[0-9;]*[a-zA-Z]/g, ""); + await data.respond(output); + } + } + }, this.logger, `adapter[${adapterId}].slash_command`)); + + await adapter.start(); + if (channelConfig.group_id) { + adapter.setChatId(String(channelConfig.group_id)); } + + adapter.on("started", safeHandler((username: string, userId?: string) => { + this.logger.info(`[${adapterId}] Bot @${username} polling started.`); + const world = this.worlds.get(adapterId); + if (world) { + world.botUsername = username; + if (userId) world.botUserId = userId; + } + }, this.logger, `adapter[${adapterId}].started`)); + + adapter.on("new_group_detected", safeHandler((data: { groupId: string; groupTitle: string; source: string }) => { + const adminMsg = `🆕 Bot added to new server:\n• Name: ${data.groupTitle}\n• ID: ${data.groupId}\n• Platform: ${data.source}\n\nTo allow: add \`${data.groupId}\` to classicBot.yaml \`allowed_guilds\``; + const generalId = this.findGeneralInstance(adapterId); + if (generalId) this.notifyInstanceTopic(generalId, adminMsg); + }, this.logger, `adapter[${adapterId}].new_group_detected`)); + adapter.on("error", (err: unknown) => { + this.logger.error({ err, adapterId }, "Additional adapter fatal error"); + this.restartAdapter(adapter, adapterId).catch(() => {}); + }); + + this.logger.info({ adapterId, type: channelConfig.type }, "Additional adapter started"); } /** Connect IPC to a single instance with all handlers */ @@ -585,8 +1179,10 @@ export class FleetManager implements FleetContext, LifecycleContext, ArchiverCon // Close existing client to prevent socket leak on reconnect const existing = this.instanceIpcClients.get(name); if (existing) { + this.ipcStoppingInstances.add(name); try { existing.close(); } catch (err) { this.logger.debug({ err, name }, "IPC client close failed (likely already closed)"); } this.instanceIpcClients.delete(name); + this.ipcStoppingInstances.delete(name); } const sockPath = join(this.getInstanceDir(name), "channel.sock"); @@ -643,39 +1239,273 @@ export class FleetManager implements FleetContext, LifecycleContext, ArchiverCon if (!this.statuslineWatcher.has(name)) { this.statuslineWatcher.watch(name); } + + // Auto-reconnect on disconnect (unless intentionally stopping) + ipc.on("disconnect", () => { + this.instanceIpcClients.delete(name); + if (this.ipcStoppingInstances.has(name)) return; + this.ipcReconnect(name).catch(() => {}); + }); } catch (err) { this.logger.warn({ name, err }, "Failed to connect to instance IPC"); } } + /** Attempt IPC reconnection with exponential backoff */ + private async ipcReconnect(name: string): Promise { + for (let attempt = 1; ; attempt++) { + if (this.ipcStoppingInstances.has(name) || !this.daemons.has(name)) return; + const delay = attempt <= 3 ? 3000 * Math.pow(2, attempt - 1) : 60_000; // 3s, 6s, 12s, then 60s + await new Promise(r => setTimeout(r, delay)); + if (this.ipcStoppingInstances.has(name) || !this.daemons.has(name)) return; + try { + await this.connectIpcToInstance(name); + if (this.instanceIpcClients.has(name)) { + this.logger.info({ name, attempt }, "IPC reconnected"); + return; + } + } catch { /* retry */ } + // Periodic pane health check (every attempt after initial 3) + if (attempt >= 3) { + const instanceDir = this.getInstanceDir(name); + const windowIdPath = join(instanceDir, "window-id"); + if (existsSync(windowIdPath)) { + const windowId = readFileSync(windowIdPath, "utf-8").trim(); + if (windowId) { + try { + const { execSync } = await import("node:child_process"); + execSync(`tmux list-panes -t "${windowId}"`, { stdio: "ignore" }); + } catch { + // Pane dead — respawn + this.logger.info({ name }, "Tmux pane dead after IPC loss — respawning instance"); + this.restartSingleInstance(name).catch(err => + this.logger.error({ name, err }, "Auto-respawn after IPC loss failed")); + return; + } + } + } + } + if (attempt % 10 === 0) { + this.logger.warn({ name, attempt }, "IPC reconnect still failing"); + } + } + } + + /** Restart a channel adapter after fatal error with infinite retry + 60s cap */ + private async restartAdapter(adapter: ChannelAdapter, id: string): Promise { + if (this.adapterRestarting.has(id)) return; + this.adapterRestarting.add(id); + try { + for (let attempt = 1; ; attempt++) { + if (this.ipcStoppingInstances.has("__fleet_stopping__")) return; + const delay = attempt <= 3 ? 5000 * Math.pow(2, attempt - 1) : 60_000; // 5s, 10s, 20s, then 60s + await new Promise(r => setTimeout(r, delay)); + if (this.ipcStoppingInstances.has("__fleet_stopping__")) return; + try { + await adapter.stop().catch(() => {}); + await adapter.start(); + this.logger.info({ id, attempt }, "Adapter restarted successfully"); + return; + } catch { /* retry */ } + if (attempt % 10 === 0) { + this.logger.warn({ id, attempt }, "Adapter restart still failing"); + } + } + } finally { + this.adapterRestarting.delete(id); + } + } + /** Handle inbound message — transcribe voice if present, then route */ - private findGeneralInstance(): string | undefined { + private findGeneralInstance(adapterId?: string): string | undefined { if (!this.fleetConfig) return undefined; + const generals: string[] = []; for (const [name, config] of Object.entries(this.fleetConfig.instances)) { - if (config.general_topic === true) { - return this.daemons.has(name) ? name : undefined; + if (config.general_topic === true && this.daemons.has(name)) { + generals.push(name); } } - return undefined; + if (generals.length === 0) return undefined; + if (generals.length === 1) return generals[0]; + if (adapterId) { + // Prefer explicit channel_id match + const byChannelId = generals.find(n => this.fleetConfig!.instances[n].channel_id === adapterId); + if (byChannelId) return byChannelId; + // Fallback: name contains adapter id + const byName = generals.find(n => n.includes(adapterId)); + if (byName) return byName; + } + return generals[0]; } private async handleInboundMessage(msg: InboundMessage): Promise { const threadId = msg.threadId || undefined; + + this.logger.debug({ source: msg.source, chatId: msg.chatId, threadId, userId: msg.userId, isBotMessage: msg.isBotMessage, textLen: (msg.text ?? "").length, text: (msg.text ?? "").slice(0, 80) }, "handleInboundMessage entry"); + + // Bot messages: only allow in collab channels or TG classic with @mention + if (msg.isBotMessage) { + if (!threadId) { + // TG classic: allow if bot @mentions our bot (bot-to-bot communication) + const world = this.worlds.get(msg.adapterId ?? ""); + const botUser = world?.botUsername; + const mentionsUs = !!(botUser && msg.text?.toLowerCase().includes(`@${botUser.toLowerCase()}`)); + this.logger.debug({ botUser, mentionsUs, isBotMessage: true, threadId: null }, "Bot message filter (no threadId path)"); + if (!mentionsUs) return; + // Fall through to TG classic handling below + } else { + const target = this.routing.resolve(threadId); + if (!target || target.kind !== "classic") return; + if (!this.classicChannels?.isCollab(threadId)) return; + // Fall through to classic channel handling + } + } + + // Access control — classic channels are open to all, others require allowed user + const am = (msg.adapterId ? this.worlds.get(msg.adapterId)?.accessManager : undefined) ?? this.accessManager; + if (am && !am.isAllowed(msg.userId)) { + const adapterGroupId = String(this.getChannelConfig(msg.adapterId)?.group_id ?? ""); + const isTelegramClassicCandidate = msg.source === "telegram" && msg.chatId !== adapterGroupId && !threadId; + if (!isTelegramClassicCandidate) { + const target = threadId ? this.routing.resolve(threadId) : undefined; + this.logger.info({ userId: msg.userId, threadId, targetKind: target?.kind, targetName: (target as any)?.name }, "Access DENIED for non-allowed user"); + if (!target || target.kind !== "classic") return; + } + } if (threadId == null) { + // ── Telegram Classic Mode ── + // Messages from chats other than the primary forum group are classic mode candidates. + // Private chats (positive chatId) and regular groups (negative, not group_id) qualify. + const adapterGroupId = String(this.getChannelConfig(msg.adapterId)?.group_id ?? ""); + const isTelegramClassic = msg.source === "telegram" && msg.chatId !== adapterGroupId; + + if (isTelegramClassic && this.classicChannels) { + const chatId = msg.chatId; + const rawText = msg.text ?? ""; + // Detect @OurBot mention (only our bot, not other bots) + const world = this.worlds.get(msg.adapterId ?? ""); + const botUser = world?.botUsername; + + // Strip @BotUsername suffix from commands — but only if it's OUR bot or no bot specified + let text = rawText; + const cmdMatch = rawText.match(/^(\/\w+)@(\S+)/); + if (cmdMatch) { + const targetBot = cmdMatch[2]; + if (botUser && targetBot.toLowerCase() !== botUser.toLowerCase()) { + // Command targeted at another bot — ignore entirely + return; + } + text = rawText.replace(/^(\/\w+)@\S+/, "$1"); + } + + const isBotMentioned = !!(botUser && text.toLowerCase().includes(`@${botUser.toLowerCase()}`)); + const isPrivateChat = !chatId.startsWith("-"); // Telegram: positive = private, negative = group + const msgAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter; + + // Handle /start command + if (text === "/start" || text.startsWith("/start ")) { + if (isPrivateChat) { + if (!this.classicChannels.isUserAllowed(msg.userId)) { + await msgAdapter?.sendText(chatId, "⛔ You are not in the allowed users list."); + return; + } + } else { + if (!this.classicChannels.isGroupAllowed(chatId)) { + // Notify admin about new group wanting access + const groupTitle = (msg as any).chatTitle || chatId; + const adminMsg = `🆕 New group detected:\n• Name: ${groupTitle}\n• ID: ${chatId}\n• User: ${msg.username} (${msg.userId})\n• Platform: ${msg.source}\n\nTo allow: add \`${chatId}\` to classicBot.yaml \`allowed_guilds\``; + const generalId = this.findGeneralInstance(msg.adapterId); + if (generalId) { + this.notifyInstanceTopic(generalId, adminMsg); + } + await msgAdapter?.sendText(chatId, "⏳ Access requested. Waiting for admin approval."); + return; + } + if (!this.classicChannels.isAdmin(msg.userId)) { + await msgAdapter?.sendText(chatId, "⛔ Only admins can start agents. Ask an admin to /start."); + const generalId = this.findGeneralInstance(msg.adapterId); + if (generalId) { + this.notifyInstanceTopic(generalId, `🔑 User wants to /start but is not admin:\n• Name: ${msg.username}\n• ID: ${msg.userId}\n• Platform: ${msg.source}\n• Group: ${chatId}\n\nTo approve: add \`${msg.userId}\` to classicBot.yaml \`admin_users\``); + } + return; + } + } + const channelName = msg.username || chatId; + const reply = await this.handleClassicStart(chatId, channelName, msg.userId); + if (msg.adapterId) this.bindInstanceAdapter(classicInstanceName(sanitizeInstanceName(channelName || chatId), chatId), msg.adapterId, true); + await msgAdapter?.sendText(chatId, reply); + return; + } + + // Handle /stop command + if (text === "/stop" || text.startsWith("/stop ")) { + if (!this.classicChannels.isAdmin(msg.userId)) { + await msgAdapter?.sendText(chatId, "⛔ Only admins can stop agents."); + const generalId = this.findGeneralInstance(msg.adapterId); + if (generalId) { + this.notifyInstanceTopic(generalId, `🔑 User wants to /stop but is not admin:\n• Name: ${msg.username}\n• ID: ${msg.userId}\n• Platform: ${msg.source}\n• Group: ${chatId}\n\nTo approve: add \`${msg.userId}\` to classicBot.yaml \`admin_users\``); + } + return; + } + const reply = await this.handleClassicStop(chatId); + await msgAdapter?.sendText(chatId, reply); + return; + } + + // Route to classic channel if registered + const target = this.routing.resolve(chatId); + if (target?.kind === "classic") { + if (msg.adapterId) this.bindInstanceAdapter(target.name, msg.adapterId, true); + // TG ClassicBot: only @mention triggers agent (both private and group). + // /chat command is NOT supported for TG classic — use @bot instead. + if (!isBotMentioned) { + // No trigger: save attachments + react, log, but don't forward to agent + const syntheticMsg = { ...msg, threadId: chatId, text: rawText.startsWith("/") ? "" : rawText }; + await this.handleClassicChannelMessage(target.name, syntheticMsg); + return; + } + // Strip @bot from text and forward as /chat + const cleanText = botUser ? text.replace(new RegExp(`@${botUser}`, "gi"), "").trim() : text; + if (cleanText.startsWith("/raw") && !this.classicChannels.isAdmin(msg.userId)) { + await msgAdapter?.sendText(chatId, "⛔ /raw requires admin access."); + return; + } + const syntheticMsg = { ...msg, threadId: chatId, text: `/chat ${cleanText}` }; + await this.handleClassicChannelMessage(target.name, syntheticMsg); + return; + } + + // Handle @bot without active agent + if (isBotMentioned) { + await msgAdapter?.sendText(chatId, "No active agent. Use /start first."); + return; + } + + // Unregistered private chat: ignore (don't fall through to General) + if (isPrivateChat) return; + // Unregistered group: ignore + return; + } + // General topic: check for /status command if (await this.topicCommands.handleGeneralCommand(msg)) return; // Forward to General Topic instance if configured - const generalInstance = this.findGeneralInstance(); + const generalInstance = this.findGeneralInstance(msg.adapterId); if (generalInstance) { + if (msg.adapterId) this.bindInstanceAdapter(generalInstance, msg.adapterId, true); + const inboundAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter!; + + // React immediately — before any other API calls + if (msg.chatId && msg.messageId) { + inboundAdapter.react(msg.threadId ?? msg.chatId, msg.messageId, "👀") + .catch(e => this.logger.debug({ err: (e as Error).message }, "Auto-react failed")); + } + this.warnIfRateLimited(generalInstance, msg); - const { text, extraMeta } = await processAttachments(msg, this.adapter!, this.logger, generalInstance); + const { text, extraMeta } = await processAttachments(msg, inboundAdapter, this.logger, generalInstance); const ipc = this.instanceIpcClients.get(generalInstance); if (ipc) { - if (this.adapter && msg.chatId && msg.messageId) { - this.adapter.react(msg.chatId, msg.messageId, "👀") - .catch(e => this.logger.debug({ err: (e as Error).message }, "Auto-react failed")); - } ipc.send({ type: "fleet_inbound", content: text, @@ -706,22 +1536,56 @@ export class FleetManager implements FleetContext, LifecycleContext, ArchiverCon const target = this.routing.resolve(threadId); if (!target) { - this.topicCommands.handleUnboundTopic(msg); + // Only show unbound message for actual forum topics (same group, has threadId) + const adapterGroupId = String(this.getChannelConfig(msg.adapterId)?.group_id ?? ""); + const isForumTopic = msg.source === "telegram" && msg.chatId === adapterGroupId && threadId; + if (isForumTopic) { + this.topicCommands.handleUnboundTopic(msg); + } return; } + + // Classic channel: log all messages, only forward /chat to agent + if (target.kind === "classic") { + if (msg.adapterId) this.bindInstanceAdapter(target.name, msg.adapterId, true); + await this.handleClassicChannelMessage(target.name, msg); + return; + } + const instanceName = target.name; - // Reopen archived topic before routing + // Intercept admin commands (/status, /restart, /sysinfo) in general topics + const instanceConfig = this.fleetConfig?.instances[instanceName]; + if (instanceConfig?.general_topic && await this.topicCommands.handleGeneralCommand(msg)) { + return; + } + + // Intercept /ctx in any instance topic + if (await this.topicCommands.handleInstanceCommand(msg, instanceName)) { + return; + } + + // Bind instance to the adapter that delivered this message + if (msg.adapterId) this.bindInstanceAdapter(instanceName, msg.adapterId, true); + + const inboundAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter!; + + // React immediately — before any other Discord API calls + if (msg.chatId && msg.messageId) { + inboundAdapter.react(msg.threadId ?? msg.chatId, msg.messageId, "👀") + .catch(e => this.logger.debug({ err: (e as Error).message }, "Auto-react failed")); + } + + // These may hit Discord API (topic icon, archive) — do after react if (this.topicArchiver.isArchived(threadId)) { await this.topicArchiver.reopen(threadId, instanceName); } this.touchActivity(instanceName); this.setTopicIcon(instanceName, "blue"); - this.warnIfRateLimited(instanceName, msg); - const { text, extraMeta } = await processAttachments(msg, this.adapter!, this.logger, instanceName); + const { text, extraMeta } = await processAttachments(msg, inboundAdapter, this.logger, instanceName); const ipc = this.instanceIpcClients.get(instanceName); if (!ipc) { @@ -729,11 +1593,6 @@ export class FleetManager implements FleetContext, LifecycleContext, ArchiverCon return; } - if (this.adapter && msg.chatId && msg.messageId) { - this.adapter.react(msg.chatId, msg.messageId, "👀") - .catch(e => this.logger.debug({ err: (e as Error).message }, "Auto-react failed")); - } - ipc.send({ type: "fleet_inbound", content: text, @@ -775,14 +1634,15 @@ export class FleetManager implements FleetContext, LifecycleContext, ArchiverCon const lastWarn = this.rateLimitWarnedAt.get(instanceName) ?? 0; if (Date.now() - lastWarn < 30 * 60_000) return; this.rateLimitWarnedAt.set(instanceName, Date.now()); - if (this.adapter && msg.chatId) { - this.adapter.sendText(msg.chatId, warning, { threadId: msg.threadId ?? undefined }).catch(() => {}); + const warnAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter; + if (warnAdapter && msg.chatId) { + warnAdapter.sendText(msg.chatId, warning, { threadId: msg.threadId ?? undefined }).catch(() => {}); } } /** Handle outbound tool calls from a daemon instance */ private async handleOutboundFromInstance(instanceName: string, msg: Record): Promise { - if (!this.adapter) return; + if (this.worlds.size === 0) return; this.touchActivity(instanceName); this.setTopicIcon(instanceName, "green"); const tool = msg.tool as string; @@ -808,10 +1668,15 @@ export class FleetManager implements FleetContext, LifecycleContext, ArchiverCon const routingConfig = senderInstanceName ? this.fleetConfig?.instances[senderInstanceName] : (senderSessionName ? undefined : this.fleetConfig?.instances[instanceName]); - const threadId = resolveReplyThreadId(args.thread_id, routingConfig); + const threadId = resolveReplyThreadId(args.thread_id, routingConfig) + ?? this.classicChannels?.getChannelIdByInstance(senderInstanceName ?? instanceName); + + // Select adapter: use instance binding, or resolve from chatId in args + const outAdapter = this.getAdapterForInstance(senderInstanceName ?? instanceName) ?? this.adapter; + if (!outAdapter) { respond(null, "No adapter available"); return; } // Route standard channel tools (reply, react, edit_message, download_attachment) - if (routeToolCall(this.adapter, tool, args, threadId, respond)) { + if (routeToolCall(outAdapter, tool, args, threadId, respond)) { if (tool === "reply") { const replyTo = this.lastInboundUser.get(instanceName) ?? "user"; this.logger.info(`${instanceName} → ${replyTo}: ${(args.text as string ?? "").slice(0, 100)}`); @@ -820,6 +1685,11 @@ export class FleetManager implements FleetContext, LifecycleContext, ArchiverCon text: (args.text as string ?? "").slice(0, 2000), ts: new Date().toISOString(), }); + // Log bot reply to classic instance chat-log + const isClassic = [...this.routing.entries()].some(([, t]) => t.kind === "classic" && t.name === instanceName); + if (isClassic) { + ClassicChannelManager.logMessage(instanceName, "bot", args.text as string ?? "", new Date()); + } } return; } @@ -839,7 +1709,8 @@ export class FleetManager implements FleetContext, LifecycleContext, ArchiverCon /** Handle tool status update from a daemon instance */ private handleToolStatusFromInstance(instanceName: string, msg: Record): void { - if (!this.adapter) return; + const statusAdapter = this.getAdapterForInstance(instanceName) ?? this.adapter; + if (!statusAdapter) return; const text = msg.text as string; const editMessageId = msg.editMessageId as string | null; @@ -851,13 +1722,13 @@ export class FleetManager implements FleetContext, LifecycleContext, ArchiverCon ? this.fleetConfig?.instances[senderInstanceName] : (senderSessionName ? undefined : this.fleetConfig?.instances[instanceName]); const threadId = routingConfig?.topic_id ? String(routingConfig.topic_id) : undefined; - const chatId = this.adapter.getChatId(); + const chatId = statusAdapter.getChatId(); if (!chatId) return; if (editMessageId) { - this.adapter.editMessage(chatId, editMessageId, text).catch(e => this.logger.debug({ err: e }, "Failed to edit tool status message")); + statusAdapter.editMessage(chatId, editMessageId, text).catch(e => this.logger.debug({ err: e }, "Failed to edit tool status message")); } else { - this.adapter.sendText(chatId, text, { threadId }).then((sent) => { + statusAdapter.sendText(chatId, text, { threadId }).then((sent) => { const ipc = this.instanceIpcClients.get(instanceName); ipc?.send({ type: "fleet_tool_status_ack", messageId: sent.messageId }); }).catch(e => this.logger.warn({ err: e }, "Failed to send tool status message")); @@ -921,17 +1792,19 @@ export class FleetManager implements FleetContext, LifecycleContext, ArchiverCon } private notifySourceTopic(schedule: Schedule): void { - if (!this.adapter) return; + const adapter = this.getAdapterForInstance(schedule.target) ?? this.adapter; + if (!adapter) return; const text = `⏰ Schedule "${schedule.label ?? schedule.id}" triggered, target: ${schedule.target}`; - this.adapter.sendText(schedule.reply_chat_id, text, { + adapter.sendText(schedule.reply_chat_id, text, { threadId: schedule.reply_thread_id ?? undefined, }).catch((err: unknown) => this.logger.error({ err }, "Failed to send cross-instance notification")); } private notifyScheduleFailure(schedule: Schedule): void { - if (!this.adapter) return; + const adapter = this.getAdapterForInstance(schedule.target) ?? this.adapter; + if (!adapter) return; const text = `⏰ Schedule "${schedule.label ?? schedule.id}" trigger failed: instance ${schedule.target} is offline.`; - this.adapter.sendText(schedule.reply_chat_id, text, { + adapter.sendText(schedule.reply_chat_id, text, { threadId: schedule.reply_thread_id ?? undefined, }).catch((err: unknown) => this.logger.error({ err }, "Failed to send schedule failure notification")); } @@ -1295,11 +2168,12 @@ export class FleetManager implements FleetContext, LifecycleContext, ArchiverCon // ===================== Topic management ===================== /** Create a forum topic via the adapter. Returns the message_thread_id. */ - async createForumTopic(topicName: string): Promise { - if (!this.adapter?.createTopic) { + async createForumTopic(topicName: string, adapterId?: string): Promise { + const adapter = (adapterId ? this.worlds.get(adapterId)?.adapter : undefined) ?? this.adapter; + if (!adapter?.createTopic) { throw new Error("Adapter does not support topic creation"); } - return this.adapter.createTopic(topicName); + return adapter.createTopic(topicName); } async deleteForumTopic(topicId: number | string): Promise { @@ -1313,6 +2187,8 @@ export class FleetManager implements FleetContext, LifecycleContext, ArchiverCon private topicCleanupTimer: ReturnType | null = null; private sessionPruneTimer: ReturnType | null = null; + private classicReloadTimer: ReturnType | null = null; + private botUserId: string | undefined; /** Periodically check if bound topics still exist */ private startTopicCleanupPoller(): void { @@ -1340,7 +2216,11 @@ export class FleetManager implements FleetContext, LifecycleContext, ArchiverCon if (!this.fleetConfig || !this.configPath) return; const toSave: Record = {}; if (this.fleetConfig.project_roots) toSave.project_roots = this.fleetConfig.project_roots; - if (this.fleetConfig.channel) toSave.channel = this.fleetConfig.channel; + if (this.fleetConfig.channels && this.fleetConfig.channels.length > 0) { + toSave.channels = this.fleetConfig.channels; + } else if (this.fleetConfig.channel) { + toSave.channel = this.fleetConfig.channel; + } if (this.fleetConfig.health_port) toSave.health_port = this.fleetConfig.health_port; if (Object.keys(this.fleetConfig.defaults).length > 0) toSave.defaults = this.fleetConfig.defaults; if (this.fleetConfig.teams && Object.keys(this.fleetConfig.teams).length > 0) { @@ -1360,6 +2240,7 @@ export class FleetManager implements FleetContext, LifecycleContext, ArchiverCon }; // Preserve all optional user-configured fields so saveFleetConfig() never silently drops them if (inst.general_topic) serialized.general_topic = true; + if (inst.channel_id) serialized.channel_id = inst.channel_id; if (inst.description) serialized.description = inst.description; if (inst.tags?.length) serialized.tags = inst.tags; if (inst.model) serialized.model = inst.model; @@ -1423,6 +2304,17 @@ export class FleetManager implements FleetContext, LifecycleContext, ArchiverCon this.statuslineWatcher.watch(name); } + reactMessageStatus(chatId: string, messageId: string, emoji: string): void { + // Find the adapter that owns this chatId (check all adapters, not just primary) + for (const [, w] of this.worlds) { + if (w.type === "discord") { + w.react(chatId, messageId, emoji) + .catch(e => this.logger.debug({ err: (e as Error).message }, "Message status react failed")); + return; + } + } + } + // ── Model failover ────────────────────────────────────────────────────── private static FAILOVER_TRIGGER_PCT = 90; @@ -1471,13 +2363,33 @@ export class FleetManager implements FleetContext, LifecycleContext, ArchiverCon } notifyInstanceTopic(instanceName: string, text: string): void { - if (!this.adapter) return; - const groupId = this.fleetConfig?.channel?.group_id; - if (!groupId) return; + const adapter = this.getAdapterForInstance(instanceName) ?? this.adapter; + if (!adapter) return; + const channelCfg = this.getChannelConfig(this.instanceWorldBinding.get(instanceName)); + const groupId = channelCfg?.group_id; + + // Fleet topic instance const threadId = this.fleetConfig?.instances[instanceName]?.topic_id; - this.adapter.sendText(String(groupId), text, { - threadId: threadId != null ? String(threadId) : undefined, - }).catch(e => this.logger.warn({ err: e, instanceName }, "Failed to send instance topic notification")); + if (threadId != null && groupId) { + adapter.sendText(String(groupId), text, { threadId: String(threadId) }) + .catch(e => this.logger.warn({ err: e, instanceName }, "Failed to send instance topic notification")); + return; + } + + // Classic instance: find chatId from routing table + for (const [chatId, target] of this.routing.entries()) { + if (target.kind === "classic" && target.name === instanceName) { + adapter.sendText(chatId, text) + .catch(e => this.logger.warn({ err: e, instanceName }, "Failed to send classic notification")); + return; + } + } + + // Fallback: send to group without threadId + if (groupId) { + adapter.sendText(String(groupId), text) + .catch(e => this.logger.warn({ err: e, instanceName }, "Failed to send notification (no topic)")); + } } queueMirrorMessage(text: string): void { @@ -1502,10 +2414,9 @@ export class FleetManager implements FleetContext, LifecycleContext, ArchiverCon /** Push an SSE event to all connected Web UI clients. */ emitSseEvent(event: string, data: unknown): void { - const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; - for (const client of this.sseClients) { - client.write(payload); - } + broadcastSseEvent(this.sseClients, event, data, (err) => + this.logger.debug({ err }, "SSE client write failed; evicting"), + ); } listClaimedTasks(assignee: string): Array<{ id: string; title: string }> { @@ -1515,14 +2426,16 @@ export class FleetManager implements FleetContext, LifecycleContext, ArchiverCon } async sendHangNotification(instanceName: string): Promise { - if (!this.adapter) return; - const groupId = this.fleetConfig?.channel?.group_id; + const adapter = this.getAdapterForInstance(instanceName) ?? this.adapter; + if (!adapter) return; + const channelCfg = this.getChannelConfig(this.instanceWorldBinding.get(instanceName)); + const groupId = channelCfg?.group_id; if (!groupId) return; const threadId = this.fleetConfig?.instances[instanceName]?.topic_id; this.setTopicIcon(instanceName, "red"); - await this.adapter.notifyAlert(String(groupId), { + await adapter.notifyAlert(String(groupId), { type: "hang", instanceName, message: `⚠️ ${instanceName} appears hung (no activity for 15+ minutes)`, @@ -1637,9 +2550,9 @@ When instances disagree, collect both viewpoints, make a decision, and record it ----- -## Context Rotation Bootstrap +## After Restart -After your context rotates, run this sequence BEFORE processing any new messages: +After a restart, run this sequence BEFORE processing any new messages: 1. list_instances() → rebuild fleet awareness 2. list_teams() → restore team structure 3. list_decisions() → reload policies and conventions @@ -1679,9 +2592,19 @@ Design Proposed → Design Approved → Implementation → Submit for Review → - Always check existing teams before creating new ones - Default to ephemeral teams (created for a specific task, dissolved after completion) - Clean up ephemeral teams and instances after task completion + +----- + +## Instance Configuration Tips + +When users create specialized instances, suggest these configurations: + +- **Reviewer instances**: Add \`pre_task_command: "/chat load reviewer-base"\` to reset context before each review, preventing influence from previous conversations. +- **Collab mode**: For multi-bot channels, use \`/collab\` to enable @mention-based triggering. +- **Cost control**: Set per-instance \`cost_guard\` for expensive backends. `; - /** Ensure the general instance has its project instructions file */ + /** Ensure the general instance has its project instructions file + knowledge */ private ensureGeneralInstructions(workDir: string, backendName?: string): void { const backend = backendName ?? "claude-code"; const filename = FleetManager.INSTRUCTIONS_FILENAME[backend] ?? "CLAUDE.md"; @@ -1691,6 +2614,53 @@ Design Proposed → Design Approved → Implementation → Submit for Review → writeFileSync(filePath, FleetManager.GENERAL_INSTRUCTIONS, "utf-8"); this.logger.info({ filePath }, "Created general instance instructions file"); } + // Sync bundled knowledge files to general's steering directory + this.syncGeneralKnowledge(workDir, backend); + } + + /** Copy general-knowledge steering + skills to the general instance's workspace */ + private syncGeneralKnowledge(workDir: string, backend: string): void { + const knowledgeDir = join(dirname(fileURLToPath(import.meta.url)), "general-knowledge"); + if (!existsSync(knowledgeDir)) return; + + // Sync steering files → .kiro/steering/ (or workDir root for non-kiro) + const steeringDir = backend === "kiro-cli" + ? join(workDir, ".kiro", "steering") + : workDir; + mkdirSync(steeringDir, { recursive: true }); + const srcSteering = join(knowledgeDir, "steering"); + if (existsSync(srcSteering)) { + for (const file of readdirSync(srcSteering)) { + if (!file.endsWith(".md")) continue; + const src = join(srcSteering, file); + const dest = join(steeringDir, file); + const newContent = readFileSync(src, "utf-8"); + try { if (existsSync(dest) && readFileSync(dest, "utf-8") === newContent) continue; } catch {} + writeFileSync(dest, newContent); + } + } + + // Sync skills → .kiro/skills/ (kiro-cli only) + if (backend === "kiro-cli") { + const srcSkills = join(knowledgeDir, "skills"); + if (existsSync(srcSkills)) { + const destSkills = join(workDir, ".kiro", "skills"); + mkdirSync(destSkills, { recursive: true }); + for (const skillDir of readdirSync(srcSkills)) { + const skillSrc = join(srcSkills, skillDir); + if (!existsSync(join(skillSrc, "SKILL.md"))) continue; + const skillDest = join(destSkills, skillDir); + mkdirSync(skillDest, { recursive: true }); + const src = join(skillSrc, "SKILL.md"); + const dest = join(skillDest, "SKILL.md"); + const newContent = readFileSync(src, "utf-8"); + try { if (existsSync(dest) && readFileSync(dest, "utf-8") === newContent) continue; } catch {} + writeFileSync(dest, newContent); + } + } + } + + this.logger.debug({ knowledgeDir, steeringDir }, "Synced general knowledge files"); } /** Fetch forum topic icon stickers and pick emoji IDs for each state */ @@ -1723,12 +2693,13 @@ Design Proposed → Design Approved → Implementation → Submit for Review → /** Set topic icon based on instance state */ setTopicIcon(instanceName: string, state: "green" | "blue" | "red" | "remove"): void { const topicId = this.fleetConfig?.instances[instanceName]?.topic_id; - if (topicId == null || !this.adapter?.editForumTopic) return; + const adapter = this.getAdapterForInstance(instanceName) ?? this.adapter; + if (topicId == null || !adapter?.editForumTopic) return; const emojiId = state === "remove" ? "" : this.topicIcons[state]; - if (emojiId == null && state !== "remove") return; // no icon resolved + if (emojiId == null && state !== "remove") return; - this.adapter.editForumTopic(topicId, { iconCustomEmojiId: emojiId }) + adapter.editForumTopic(topicId, { iconCustomEmojiId: emojiId }) .catch((e) => this.logger.debug({ err: e, instanceName, state }, "Topic icon update failed")); } @@ -1745,10 +2716,322 @@ Design Proposed → Design Approved → Implementation → Submit for Review → this.failoverActive.clear(); } + // ── Classic Channel Methods ────────────────────────────────────────── + + /** Handle a message in a classic channel: log it, forward only /chat messages */ + private async handleClassicChannelMessage(instanceName: string, msg: InboundMessage): Promise { + const text = msg.text ?? ""; + const channelId = msg.threadId ?? msg.chatId; + const isCollabMode = this.classicChannels?.isCollab(channelId) ?? false; + + // Collab mode: trigger on @mention of our bot, log all messages + if (isCollabMode) { + // Skip empty bot messages (e.g., reactions) — don't pollute chat log + if (msg.isBotMessage && !text && !msg.attachments?.length) return; + + // Log every message (including other bots) to chat-logs + const collabAttachTag = msg.attachments?.length + ? ` [${msg.attachments.map(a => `${a.kind === "photo" ? "📷" : "📎"} ${a.filename || a.kind}`).join(", ")}]` + : ""; + ClassicChannelManager.logMessage(instanceName, msg.username, text + collabAttachTag, msg.timestamp, msg.replyToText); + this.logger.info({ instanceName, user: msg.username, textLen: text.length, attachments: msg.attachments?.length ?? 0, source: msg.source }, "Collab mode message"); + + // Check for @mention trigger: must be exact <@BOT_USER_ID>, not @everyone/@here + const adapterBotUserId = this.worlds.get(msg.adapterId ?? "")?.botUserId ?? this.botUserId; + const mentionTag = adapterBotUserId ? `<@${adapterBotUserId}>` : null; + const isMentioned = mentionTag && text.includes(mentionTag); + if (!isMentioned) { + // Save bare attachments (stickers, images) even without @mention + if (msg.attachments?.length) { + const saved = await this.saveClassicAttachment(instanceName, msg); + if (saved) { + const reactAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter; + const noMentionReactChatId = msg.threadId ?? msg.chatId; + if (reactAdapter && noMentionReactChatId && msg.messageId) { + const emoji = msg.source === "telegram" + ? (saved.kind === "photo" ? "👌" : "👍") + : (saved.kind === "photo" ? "📸" : "📎"); + reactAdapter.react(noMentionReactChatId, msg.messageId, emoji) + .catch(e => this.logger.debug({ err: (e as Error).message }, "Auto-react failed")); + } + } + } + return; + } + + // Strip the @mention from text + const cleanText = text.replace(new RegExp(`<@${adapterBotUserId}>`, "g"), "").trim(); + if (!cleanText && !msg.attachments?.length) return; + + const classicAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter; + const collabReactChatId = msg.threadId ?? msg.chatId; + if (classicAdapter && collabReactChatId && msg.messageId) { + classicAdapter.react(collabReactChatId, msg.messageId, "👀") + .catch(e => this.logger.debug({ err: (e as Error).message }, "Auto-react failed")); + } + + // Block /raw bypass + if (cleanText.startsWith("/raw ")) return; + + // Save and process attachments (same as /chat mode) + const saved = await this.saveClassicAttachment(instanceName, msg); + if (saved && classicAdapter && collabReactChatId && msg.messageId) { + const emoji = msg.source === "telegram" + ? (saved.kind === "photo" ? "👌" : "👍") + : (saved.kind === "photo" ? "📸" : "📎"); + classicAdapter.react(collabReactChatId, msg.messageId, emoji) + .catch(e => this.logger.debug({ err: (e as Error).message }, "Auto-react failed")); + } + // Strip saved attachment to avoid double download + const savedKind = saved?.kind; + const patchedAttachments = savedKind ? msg.attachments?.filter(a => a.kind !== savedKind) : msg.attachments; + const patchedMsg = { ...msg, text: cleanText, attachments: patchedAttachments?.length ? patchedAttachments : undefined }; + const { text: processedText, extraMeta } = await processAttachments(patchedMsg, classicAdapter!, this.logger, instanceName); + let finalText = processedText || cleanText; + if (saved) { + if (saved.kind === "photo") { + extraMeta.image_path = saved.paths[0]; + if (saved.paths.length > 1) extraMeta.image_paths = saved.paths.join(","); + const tags = saved.paths.map(p => `[📷 Image: ${p}]`).join("\n"); + finalText = `${tags}\n${finalText}`; + } else { + extraMeta.attachment_path = saved.paths[0]; + if (saved.paths.length > 1) extraMeta.attachment_paths = saved.paths.join(","); + const docAtts = msg.attachments?.filter(a => a.kind === "document") ?? []; + const tags = saved.paths.map((p, i) => { + const filename = docAtts[i]?.filename ?? "file"; + return `[📎 File: ${filename} → ${p}]`; + }).join("\n"); + finalText = `${tags}\n${finalText}`; + } + } + + await this.forwardToClassicInstance(instanceName, finalText, msg, extraMeta); + return; + } + + // Normal mode: /chat trigger + const isChat = text.startsWith("/chat ") || text === "/chat"; + this.logger.info({ instanceName, user: msg.username, textLen: text.length, hasChat: isChat }, "classic channel message received"); + + // Save photos/documents to workspace inbox so agent can read them later + const saved = await this.saveClassicAttachment(instanceName, msg); + + // Log every message to the daily chat log (include saved path) + const attachmentTag = saved ? ` [${saved.kind === "photo" ? "📷" : "📎"} saved: ${saved.paths.join(", ")}]` + : msg.attachments?.length ? ` [${msg.attachments.map(a => `📎 ${a.kind}${a.filename ? `: ${a.filename}` : ""}`).join(", ")}]` + : ""; + ClassicChannelManager.logMessage(instanceName, msg.username, text + attachmentTag, msg.timestamp, msg.replyToText); + + // Bare attachment without /chat: save + log only, don't trigger agent + if (!isChat) { + const reactAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter; + const reactChatId = msg.threadId ?? msg.chatId; + if (saved && reactAdapter && reactChatId && msg.messageId) { + // Telegram only supports limited emoji for reactions; use 👌 for photo, 👍 for file + const emoji = msg.source === "telegram" + ? (saved.kind === "photo" ? "👌" : "👍") + : (saved.kind === "photo" ? "📸" : "📎"); + reactAdapter.react(reactChatId, msg.messageId, emoji) + .catch(e => this.logger.debug({ err: (e as Error).message }, "Auto-react failed")); + } + return; + } + + // /chat message: forward to agent + const chatText = text.replace(/^\/chat\s*/, "").trim(); + if (!chatText && !msg.attachments?.length) return; + // Block /raw bypass — admin commands must go through slash command gate + if (chatText.startsWith("/raw ")) return; + + // Strip saved attachment from attachments to avoid double download + const savedKind = saved?.kind; + const patchedAttachments = savedKind ? msg.attachments?.filter(a => a.kind !== savedKind) : msg.attachments; + const patchedMsg = { ...msg, text: chatText, attachments: patchedAttachments?.length ? patchedAttachments : undefined }; + const classicMsgAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter!; + const { text: processedText, extraMeta } = await processAttachments(patchedMsg, classicMsgAdapter, this.logger, instanceName); + + // Use workspace inbox path for saved attachment + let finalText = processedText || chatText; + if (saved) { + if (saved.kind === "photo") { + extraMeta.image_path = saved.paths[0]; + if (saved.paths.length > 1) extraMeta.image_paths = saved.paths.join(","); + const tags = saved.paths.map(p => `[📷 Image: ${p}]`).join("\n"); + finalText = `${tags}\n${chatText}`; + } else { + extraMeta.attachment_path = saved.paths[0]; + if (saved.paths.length > 1) extraMeta.attachment_paths = saved.paths.join(","); + const docAtts = msg.attachments?.filter(a => a.kind === "document") ?? []; + const tags = saved.paths.map((p, i) => { + const filename = docAtts[i]?.filename ?? "file"; + return `[📎 File: ${filename} → ${p}]`; + }).join("\n"); + finalText = `${tags}\n${chatText}`; + } + } + + if (msg.chatId && msg.messageId) { + const reactChatId = msg.threadId ?? msg.chatId; + classicMsgAdapter.react(reactChatId, msg.messageId, "👀") + .catch(e => this.logger.debug({ err: (e as Error).message }, "Auto-react failed")); + if (saved) { + const savedEmoji = msg.source === "telegram" + ? (saved.kind === "photo" ? "👌" : "👍") + : (saved.kind === "photo" ? "📸" : "📎"); + classicMsgAdapter.react(reactChatId, msg.messageId, savedEmoji) + .catch(e => this.logger.debug({ err: (e as Error).message }, "Auto-react failed")); + } + } + + await this.forwardToClassicInstance(instanceName, finalText, msg, extraMeta); + } + + /** Download photo or document attachment to classic instance workspace inbox. Returns { path, kind } or undefined. */ + private async saveClassicAttachment(instanceName: string, msg: InboundMessage): Promise<{ path: string; paths: string[]; kind: "photo" | "document" } | undefined> { + const atts = msg.attachments?.filter(a => a.kind === "photo" || a.kind === "document" || a.kind === "sticker") ?? []; + const dlAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter; + if (atts.length === 0 || !dlAdapter) return undefined; + const paths: string[] = []; + let kind: "photo" | "document" = "document"; + for (const att of atts) { + try { + const tmpPath = await dlAdapter.downloadAttachment(att.fileId); + const inboxDir = join(getAgendHome(), "workspaces", instanceName, "inbox"); + mkdirSync(inboxDir, { recursive: true }); + const dest = join(inboxDir, basename(tmpPath)); + try { renameSync(tmpPath, dest); } catch { copyFileSync(tmpPath, dest); unlinkSync(tmpPath); } + const savedKind = att.kind === "sticker" ? "photo" : att.kind; + paths.push(dest); + if (paths.length === 1) kind = savedKind as "photo" | "document"; + this.logger.info({ instanceName, path: dest, kind: savedKind }, "Classic attachment saved to workspace inbox"); + } catch (err) { + this.logger.warn({ err: (err as Error).message, instanceName }, "Classic attachment save failed"); + } + } + if (paths.length === 0) return undefined; + return { path: paths[0], paths, kind }; + } + + /** Forward a message to a classic channel instance with chat log context */ + private async forwardToClassicInstance( + instanceName: string, + text: string, + msg: { chatId: string; threadId?: string; messageId: string; userId: string; username: string; source: string; timestamp: Date; replyToText?: string }, + extraMeta?: Record, + ): Promise { + const contextLines = this.classicChannels?.getContextLines(msg.chatId) ?? 5; + const logContext = this.getRecentChatLog(instanceName, contextLines); + const fullText = logContext + ? `[Chat log for context]\n${logContext}\n\n[User message]\n${text}` + : text; + + const ipc = this.instanceIpcClients.get(instanceName); + if (!ipc) { + this.logger.warn({ instanceName }, "Classic channel instance IPC not connected"); + return; + } + + ipc.send({ + type: "fleet_inbound", + content: fullText, + targetSession: instanceName, + meta: { + chat_id: msg.chatId, + message_id: msg.messageId, + user: msg.username, + user_id: msg.userId, + ts: msg.timestamp.toISOString(), + thread_id: msg.threadId ?? "", + source: msg.source, + ...extraMeta, + ...(msg.replyToText ? { reply_to_text: msg.replyToText } : {}), + }, + }); + this.lastInboundUser.set(instanceName, msg.username); + this.logger.info(`${msg.username} → ${instanceName} (classic): ${text.slice(0, 100)}`); + } + + /** Paste raw text directly to a classic instance's CLI (no [user:] wrapping) */ + private pasteRawToClassicInstance(instanceName: string, text: string): void { + const ipc = this.instanceIpcClients.get(instanceName); + if (!ipc) { + this.logger.warn({ instanceName }, "Cannot paste raw: IPC not connected"); + return; + } + ipc.send({ type: "raw_paste", content: text }); + this.logger.info({ instanceName, text: text.slice(0, 100) }, "Raw paste sent to classic instance"); + } + + /** Read recent chat log for agent context */ + private getRecentChatLog(instanceName: string, maxLines = 10): string | undefined { + const logDir = ClassicChannelManager.chatLogDir(instanceName); + const today = new Date().toISOString().slice(0, 10); + const logFile = join(logDir, `${today}.log`); + try { + if (!existsSync(logFile)) return undefined; + const lines = readFileSync(logFile, "utf-8").trim().split("\n"); + return lines.slice(-maxLines).join("\n"); + } catch { return undefined; } + } + + /** Start a classic channel instance with lightweight config */ + private async startClassicInstance(instanceName: string, backend?: string, preTaskCommand?: string, model?: string): Promise { + if (this.daemons.has(instanceName)) return; + const workDir = join(getAgendHome(), "workspaces", instanceName); + ensureWorkspaceGit(workDir); + const config: InstanceConfig = { + ...DEFAULT_INSTANCE_CONFIG, + ...this.fleetConfig?.defaults, + working_directory: workDir, + lightweight: true, + ...(backend ? { backend } : {}), + ...(model ? { model } : {}), + ...(preTaskCommand ? { pre_task_command: preTaskCommand } : {}), + }; + const topicMode = this.fleetConfig?.channel?.mode === "topic"; + await this.startInstance(instanceName, config, topicMode); + } + + /** Handle /start slash command — register classic channel */ + async handleClassicStart(channelId: string, channelName: string, userId: string, guildId?: string): Promise { + if (!this.classicChannels) return "Classic channel manager not initialized."; + if (guildId && !this.classicChannels.isGuildAllowed(guildId)) return "⛔ This server is not in the allowed guilds list."; + if (this.classicChannels.isClassicChannel(channelId)) return "This channel already has an active agent. Use /chat to talk."; + if (this.routing.resolve(channelId)) return "This channel is already bound to a topic-mode instance."; + + const instanceName = classicInstanceName(sanitizeInstanceName(channelName || channelId), channelId); + this.classicChannels.register(channelId, instanceName, channelName || channelId, userId); + this.routing.register(channelId, { kind: "classic", name: instanceName }); + + await this.startClassicInstance(instanceName, this.classicChannels.getBackend(channelId, this.fleetConfig?.defaults?.backend), this.classicChannels.getPreTaskCommand(channelId), this.classicChannels.getModel(channelId, this.fleetConfig?.defaults?.model)); + this.reregisterClassicChannels(); + this.logger.info({ channelId, instanceName, userId }, "Classic channel started"); + return `✅ Agent started in this channel. Use \`/chat \` to talk.`; + } + + /** Handle /stop slash command — unregister classic channel */ + async handleClassicStop(channelId: string): Promise { + if (!this.classicChannels) return "Classic channel manager not initialized."; + const ch = this.classicChannels.unregister(channelId); + if (!ch) return "No active agent in this channel."; + + this.routing.unregister(channelId); + await this.stopInstance(ch.instanceName).catch(err => + this.logger.warn({ err, instanceName: ch.instanceName }, "Failed to stop classic instance")); + this.reregisterClassicChannels(); + this.logger.info({ channelId, instanceName: ch.instanceName }, "Classic channel stopped"); + return `🛑 Agent stopped in this channel.`; + } + async stopAll(): Promise { + this.ipcStoppingInstances.add("__fleet_stopping__"); + sdNotify("STOPPING=1"); + if (this.watchdogTimer) { clearInterval(this.watchdogTimer); this.watchdogTimer = null; } this.clearStatuslineWatchers(); this.costGuard?.stop(); this.dailySummary?.stop(); + if (this.updateCheckTimer) { clearTimeout(this.updateCheckTimer as any); clearInterval(this.updateCheckTimer as any); this.updateCheckTimer = null; } if (this.topicCleanupTimer) { clearInterval(this.topicCleanupTimer); @@ -1763,31 +3046,43 @@ Design Proposed → Design Approved → Implementation → Submit for Review → this.mirrorTimer = null; this.mirrorBuffer = []; } + if (this.classicReloadTimer) { + clearInterval(this.classicReloadTimer); + this.classicReloadTimer = null; + } this.topicArchiver.stop(); this.scheduler?.shutdown(); - // Stop instances sequentially to avoid tmux send-keys race conditions. - // Each stop sends quit + Enter via separate tmux commands; parallel stops - // can cause the Enter to arrive before the quit text is processed. - for (const [name, daemon] of this.daemons) { - try { - await daemon.stop(); - } catch (err) { - this.logger.warn({ name, err }, "Stop failed"); - } - this.daemons.delete(name); + // Stop instances in parallel batches to avoid long sequential waits. + // Concurrency limited to avoid overwhelming the tmux server. + const STOP_CONCURRENCY = 5; + const entries = [...this.daemons.entries()]; + for (const [name] of entries) this.ipcStoppingInstances.add(name); + for (let i = 0; i < entries.length; i += STOP_CONCURRENCY) { + const batch = entries.slice(i, i + STOP_CONCURRENCY); + await Promise.all(batch.map(async ([name, daemon]) => { + try { + await daemon.stop(); + } catch (err) { + this.logger.warn({ name, err }, "Stop failed"); + } + this.daemons.delete(name); + })); } for (const [, ipc] of this.instanceIpcClients) { await ipc.close(); } this.instanceIpcClients.clear(); + this.ipcStoppingInstances.clear(); - if (this.adapter) { - await this.adapter.stop(); - this.adapter = null; + for (const [, w] of this.worlds) { + await w.stop().catch(() => {}); } + this.adapter = null; + this.worlds.clear(); + (this.adapters as Map).clear(); this.controlClient?.stop(); this.controlClient = null; @@ -1927,6 +3222,7 @@ Design Proposed → Design Approved → Implementation → Submit for Review → const oldConfig = this.fleetConfig; this.loadConfig(this.configPath); this.routing.rebuild(this.fleetConfig!); + this.reregisterClassicChannels(); this.scheduler?.reload(); const newInstances = this.fleetConfig!.instances; @@ -1939,9 +3235,10 @@ Design Proposed → Design Approved → Implementation → Submit for Review → this.logger.warn("Fleet-level config changed (channel/defaults) — use /restart for full effect"); } - // Stop removed instances + // Stop removed instances (skip classic bot instances — they're managed by classicBot.yaml) + const classicNames = new Set(this.classicChannels?.getAll().map(ch => ch.instanceName) ?? []); for (const name of this.daemons.keys()) { - if (!(name in newInstances)) { + if (!(name in newInstances) && !classicNames.has(name)) { this.logger.info({ name }, "Instance removed from config — stopping"); await this.stopInstance(name).catch(err => this.logger.error({ err, name }, "Failed to stop removed instance")); @@ -2047,14 +3344,41 @@ Design Proposed → Design Approved → Implementation → Submit for Review → const fleet = this.loadConfig(this.configPath); this.fleetConfig = fleet; - const topicMode = fleet.channel?.mode === "topic"; - - await this.startInstancesWithConcurrency(Object.entries(fleet.instances), topicMode); + const topicMode = fleet.channel?.mode === "topic" || !!fleet.channels?.some(ch => ch.mode === "topic"); + + // Phase 1: generals first + const restartEntries = Object.entries(fleet.instances); + const restartGenerals = restartEntries.filter(([_, cfg]) => cfg.general_topic); + const restartOthers = restartEntries.filter(([_, cfg]) => !cfg.general_topic); + for (const [name, cfg] of restartGenerals) { + await this.startInstance(name, cfg, topicMode).catch(err => + this.logger.error({ err, name }, "Failed to start general instance")); + } + if (restartOthers.length > 0) { + await this.startInstancesWithConcurrency(restartOthers, topicMode); + } if (topicMode) { this.routing.rebuild(this.fleetConfig!); + this.reregisterClassicChannels(); // startInstance already calls connectIpcToInstance, no need for connectToInstances here + // Restart classic channel instances (killed during orphan cleanup) + if (this.classicChannels) { + const fleetBackend = this.fleetConfig?.defaults?.backend; + const channels = this.classicChannels.getAll(); + const concurrency = 3; + let idx = 0; + while (idx < channels.length) { + const batch = channels.slice(idx, idx + concurrency); + await Promise.allSettled(batch.map(ch => + this.startClassicInstance(ch.instanceName, this.classicChannels!.getBackendByInstance(ch.instanceName, fleetBackend)).catch(err => + this.logger.warn({ err, instanceName: ch.instanceName }, "Failed to start classic instance")) + )); + idx += concurrency; + } + } + for (const name of Object.keys(fleet.instances)) { this.startStatuslineWatcher(name); } @@ -2071,40 +3395,68 @@ Design Proposed → Design Approved → Implementation → Submit for Review → await this.adapter.sendText(String(groupId), restartText, notifyOpts) .catch(e => this.logger.warn({ err: e }, "Failed to post restart completion notification")); - // Notify each instance's channel — tailor message based on session state + // Notify each instance's channel — staggered to avoid rate limit storm const instances = Object.entries(this.fleetConfig?.instances ?? {}); - this.logger.info({ count: instances.length }, "Sending restart notification to instances"); - for (const [name, config] of instances) { - const threadId = config.topic_id != null ? String(config.topic_id) : undefined; - const daemon = this.daemons.get(name); - const isNewSession = daemon?.isNewSession ?? false; - const msg = isNewSession - ? "Fleet restart complete. Configuration changed — starting fresh session." - : "Fleet restart complete. Continue from where you left off."; - - // Send to topic so the message appears in the instance's channel - if (threadId) { - this.adapter.sendText(String(groupId), msg, { threadId }) - .catch(e => this.logger.warn({ err: e, name, threadId }, "Failed to post per-instance restart notification")); - } - - // Push to daemon IPC so the CLI session receives the message - const ipc = this.instanceIpcClients.get(name); - if (ipc?.connected) { - ipc.send({ - type: "fleet_inbound", - content: msg, - meta: { - chat_id: String(groupId), - thread_id: threadId ?? "", - ts: new Date().toISOString(), - }, - }); + this.logger.info({ count: instances.length }, "Sending restart notification to instances (staggered)"); + const BATCH_SIZE = 3; + const BATCH_DELAY_MS = 2500; + for (let i = 0; i < instances.length; i += BATCH_SIZE) { + if (i > 0) await new Promise(r => setTimeout(r, BATCH_DELAY_MS)); + const batch = instances.slice(i, i + BATCH_SIZE); + for (const [name, config] of batch) { + const threadId = config.topic_id != null ? String(config.topic_id) : undefined; + const daemon = this.daemons.get(name); + const isNewSession = daemon?.isNewSession ?? false; + const msg = isNewSession + ? "Fleet restart complete. Configuration changed — starting fresh session." + : "Fleet restart complete. Continue from where you left off."; + + if (threadId) { + this.adapter.sendText(String(groupId), msg, { threadId }) + .catch(e => this.logger.warn({ err: e, name, threadId }, "Failed to post per-instance restart notification")); + } + + const ipc = this.instanceIpcClients.get(name); + if (ipc?.connected) { + ipc.send({ + type: "fleet_inbound", + content: msg, + meta: { + chat_id: String(groupId), + thread_id: threadId ?? "", + ts: new Date().toISOString(), + }, + }); + } } } } } + // ── Update check ──────────────────────────────────────────────────── + + private async checkForUpdates(): Promise { + try { + const { execSync } = await import("node:child_process"); + const pkgPath = join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"); + const currentVersion = JSON.parse(readFileSync(pkgPath, "utf-8")).version ?? "0.0.0"; + const latest = execSync("npm view @songsid/agend version", { stdio: "pipe", timeout: 15_000 }).toString().trim(); + let target = latest; + if (currentVersion.includes("-beta")) { + try { + const beta = execSync("npm view @songsid/agend@beta version", { stdio: "pipe", timeout: 15_000 }).toString().trim(); + if (beta && beta !== currentVersion) target = beta; + } catch { /* no beta tag */ } + } + if (target && target !== currentVersion) { + const generalId = this.findGeneralInstance(); + if (generalId) { + this.notifyInstanceTopic(generalId, `🆕 AgEnD v${target} available (current: v${currentVersion}). Use /update to upgrade.`); + } + } + } catch { /* silent — network issues */ } + } + // ── Health HTTP endpoint ───────────────────────────────────────────── private startHealthServer(port: number): void { @@ -2113,7 +3465,13 @@ Design Proposed → Design Approved → Implementation → Submit for Review → // Generate web token before server starts so auth is enforced from the first request. this.webToken = randomBytes(24).toString("hex"); const tokenPath = join(this.dataDir, "web.token"); - writeFileSync(tokenPath, this.webToken); + writeFileSync(tokenPath, this.webToken, { mode: 0o600 }); + // Defensive: if file existed previously with looser perms, tighten it. + try { + chmodSync(tokenPath, 0o600); + } catch { + // best-effort + } this.healthServer = createServer((req, res) => { res.setHeader("Content-Type", "application/json"); @@ -2121,6 +3479,8 @@ Design Proposed → Design Approved → Implementation → Submit for Review → // Public health probe — no auth required. if (req.method === "GET" && req.url === "/health") { // fallthrough to existing handler below + } else if (req.method === "POST" && req.url === "/agent") { + // /agent handles its own instance-level auth via X-Agend-Instance-Token } else { // All other endpoints require a valid token (query ?token= or X-Agend-Token header). // /ui/* will also re-check in web-api.ts, which is harmless. diff --git a/src/general-knowledge/skills/fleet-config/SKILL.md b/src/general-knowledge/skills/fleet-config/SKILL.md new file mode 100644 index 00000000..40fef026 --- /dev/null +++ b/src/general-knowledge/skills/fleet-config/SKILL.md @@ -0,0 +1,65 @@ +--- +name: fleet-config +description: fleet.yaml and classicBot.yaml structure, validation, common mistakes +--- + +## Configuration Quick Reference + +**fleet.yaml structure:** +```yaml +channel: # Telegram/Discord connection +defaults: # Shared defaults for all instances + backend: kiro-cli + startup: + concurrency: 6 # Max simultaneous instance startups + stagger_delay_ms: 2000 # Delay between startup batches +instances: # Per-instance config (topic_id, working_directory, etc.) +templates: # Reusable fleet deployment templates +``` + +**classicBot.yaml** — manages classic bot channels (separate from fleet.yaml): +- `defaults.allowed_guilds` — Discord server whitelist +- `defaults.allowed_groups` — Telegram group whitelist +- `channels` — per-channel backend override +- Hot-reloads every 30 seconds (no restart needed) + +**Key config locations:** +- Fleet config: `~/.agend/fleet.yaml` +- Classic bot: `~/.agend/classicBot.yaml` +- Environment: `~/.agend/.env` (bot tokens, API keys) +- Instance logs: `~/.agend/instances//output.log` +- Fleet log: `~/.agend/fleet.log` + +## Config Validation + +**Before editing fleet.yaml or classicBot.yaml, always validate after:** + +```bash +# Validate fleet.yaml syntax +agend fleet start --dry-run 2>&1 | head -5 +# Or simply: +node -e "const yaml = require('js-yaml'); const fs = require('fs'); yaml.load(fs.readFileSync('$HOME/.agend/fleet.yaml', 'utf-8')); console.log('✓ valid YAML')" +``` + +**Common fleet.yaml mistakes:** +- Missing `channel.mode` field → error on start +- Wrong indentation (YAML is indent-sensitive) +- `topic_id` as string vs number (both work, but be consistent) +- `backend` typo (valid: `claude-code`, `gemini-cli`, `codex`, `opencode`, `kiro-cli`, `antigravity`) +- `model` using wrong format for the backend + +**classicBot.yaml validation:** +```bash +node -e "const yaml = require('js-yaml'); const fs = require('fs'); yaml.load(fs.readFileSync('$HOME/.agend/classicBot.yaml', 'utf-8')); console.log('✓ valid YAML')" +``` + +**Common classicBot.yaml mistakes:** +- `allowed_guilds` values must be strings (Discord IDs are too large for YAML integers) +- Channel IDs as keys must be quoted strings +- Missing `defaults` section (optional but recommended) + +**After editing config:** +```bash +agend reload # hot-reload (SIGHUP) — adds/removes instances without restart +agend fleet restart # if channel/defaults changed — needs full restart +``` diff --git a/src/general-knowledge/skills/fleet-health/SKILL.md b/src/general-knowledge/skills/fleet-health/SKILL.md new file mode 100644 index 00000000..1cf4f9d3 --- /dev/null +++ b/src/general-knowledge/skills/fleet-health/SKILL.md @@ -0,0 +1,55 @@ +--- +name: fleet-health +description: Check instance health via tmux, detect stuck agents, fleet-wide health scan +--- + +## Instance Health Check via tmux + +When user asks to check an instance's status or what it's doing: +- Use `execute_bash` to run: `tmux capture-pane -t agend: -p | tail -20` +- This shows the actual CLI screen (what the agent sees right now) +- More useful than just "running/stopped" status +- If the instance appears stuck, suggest `/raw /compact` or restart + +## Fleet Health Check + +Check all instances for stuck/error state: + +```bash +for win in $(tmux list-windows -t agend -F '#{window_name}' | grep -v bash); do + last=$(tmux capture-pane -t "agend:$win" -p | tail -3 | tr '\n' ' ') + if echo "$last" | grep -q "!>"; then + echo "✅ $win — idle" + elif echo "$last" | grep -q "error:"; then + echo "❌ $win — ERROR" + else + echo "⏳ $win — busy" + fi +done +``` + +States: +- ✅ idle — prompt visible (X% !>), ready for input +- ⏳ busy — processing a task, wait for it to finish +- ❌ error — check tmux pane for details, may need restart + +If an instance is stuck (busy for >10 minutes with no output), restart it: +- `restart_instance("")` + +## Unsticking a Frozen Instance via tmux + +If an instance is frozen (not responding, no output, no prompt): +1. Send Ctrl+C via tmux to interrupt the current operation: + ```bash + tmux send-keys -t agend: C-c + ``` +2. Wait a few seconds, then check if it returned to idle: + ```bash + tmux capture-pane -t agend: -p | tail -5 + ``` +3. If it shows the prompt (`X% !>` or `(To exit the CLI...)`) — it's unstuck. Resend the task. +4. If still frozen after Ctrl+C, use `restart_instance("")` + +**When to use Ctrl+C vs restart:** +- Ctrl+C: instance is alive but stuck on a long operation (API timeout, large file read, infinite loop) +- restart: instance is completely dead (no tmux pane, crash loop, or Ctrl+C doesn't help) diff --git a/src/general-knowledge/skills/fleet-restart/SKILL.md b/src/general-knowledge/skills/fleet-restart/SKILL.md new file mode 100644 index 00000000..2e758076 --- /dev/null +++ b/src/general-knowledge/skills/fleet-restart/SKILL.md @@ -0,0 +1,48 @@ +--- +name: fleet-restart +description: Fleet restart types, recovery from tmux crash, rate limit handling, safe update +--- + +## Fleet Restart & Recovery + +**Restart types:** +- `agend fleet restart` — full stop + start (picks up new code after build & link) +- `agend reload` — SIGHUP hot-reload, reconciles instances without restarting the fleet process +- `restart_instance("")` — single instance restart, reloads fleet.yaml first + +**After tmux crash:** +- Fleet auto-detects tmux server death and triggers circuit breaker (30s pause) +- Some instances may fail to restart due to rate limits from simultaneous startup +- Fix: manually restart failed instances, or do another `agend fleet restart` +- Check failed instances: `agend ls` shows "stopped" status + +**Rate limit recovery:** +- If you see "PTY error: Rate limit reached" or "crash loop — respawn paused", wait 1-2 minutes +- Then `restart_instance` the affected instance +- Do NOT restart all instances simultaneously — this worsens rate limits + +## Safe Update & Restart + +**Update AgEnD to latest version:** +```bash +agend update # update to latest +agend update --version 0.0.6 # pin specific version +``` + +The `agend update` command automatically: +- Detects if sudo is needed (switches to nvm if so) +- Installs new version +- Verifies installation succeeded +- Updates service file (ExecStart path) +- Restarts fleet + +**Manual restart (if update isn't needed):** +```bash +agend fleet restart # graceful restart (SIGUSR2) — keeps sessions, reloads config +agend fleet stop && agend fleet start # full restart — new code takes effect +``` + +**NEVER do:** +- `kill -9` on the fleet process (corrupts state) +- Edit fleet.yaml while fleet is restarting +- Run `agend update` while another update is in progress diff --git a/src/general-knowledge/skills/instance-lifecycle/SKILL.md b/src/general-knowledge/skills/instance-lifecycle/SKILL.md new file mode 100644 index 00000000..62cf5a1e --- /dev/null +++ b/src/general-knowledge/skills/instance-lifecycle/SKILL.md @@ -0,0 +1,20 @@ +--- +name: instance-lifecycle +description: Replace vs restart instances, monitoring state, when to use each +--- + +## Instance Lifecycle Management + +**Replace vs Restart:** +- `restart_instance` — keeps session, reloads config. Use when config changed. +- `replace_instance` — kills old, creates fresh with handover context. Use when context is polluted or instance is stuck in a loop. + +**When to replace (not restart):** +- Instance keeps hallucinating or referencing stale information +- Instance is stuck in a tool-call loop +- Context is reported >80% full and responses are degrading (only applicable to backends that report context usage) + +**Monitoring instance state:** +- `describe_instance("")` — shows status, last activity, description +- `tmux capture-pane -t agend: -p | tail -20` — see actual CLI screen +- Look for `X% !>` prompt = idle, `Thinking...` = busy, `error` = needs attention diff --git a/src/general-knowledge/skills/model-discovery/SKILL.md b/src/general-knowledge/skills/model-discovery/SKILL.md new file mode 100644 index 00000000..b26d94fd --- /dev/null +++ b/src/general-knowledge/skills/model-discovery/SKILL.md @@ -0,0 +1,40 @@ +--- +name: model-discovery +description: List available models per backend, configure model in fleet.yaml +--- + +## Model Names by Backend + +Models are specified in fleet.yaml `defaults.model` or per-instance `model` field. + +| Backend | How to list models | Default | +|---------|-------------------|---------| +| **kiro-cli** | In tmux: send `/model` + Enter → read model list → Esc to close | auto (latest) | +| **claude-code** | `sonnet`, `opus`, `haiku`, `opusplan`, `best`, `sonnet[1m]`, `opus[1m]` | sonnet | +| **antigravity** | Run `agy models` to see available models | Gemini 3.5 Flash (Medium) | +| **codex** | `gpt-4o`, `o3`, `o4-mini` | gpt-4o | +| **opencode** | `opencode models` | depends on provider | + +**To discover available models for a backend, run the CLI's model listing command:** +- `agy models` — lists all available models for antigravity +- `opencode models` — lists all available models for opencode +- `codex` — check config.toml + +**Important for antigravity (agy):** +- `agy models` shows names like `Gemini 3.5 Flash (Medium)` — the parenthetical suffix (Medium/High/Low/Thinking) is the **effort level**, NOT part of the model name. +- When setting model in fleet.yaml, use only the base name WITHOUT the effort suffix. +- Example: `agy models` shows `Gemini 3.5 Flash (Medium)` → set `model: "Gemini 3.5 Flash"` +- Example: `agy models` shows `Claude Opus 4.6 (Thinking)` → set `model: "Claude Opus 4.6"` + +**Important:** Model names vary by backend. Always check the actual CLI output rather than guessing names. + +Example fleet.yaml: +```yaml +defaults: + backend: kiro-cli + model: claude-sonnet-4-20250514 + +instances: + heavy-task: + model: claude-opus-4-20250514 +``` diff --git a/src/general-knowledge/skills/multi-channel/SKILL.md b/src/general-knowledge/skills/multi-channel/SKILL.md new file mode 100644 index 00000000..0a2ea7f6 --- /dev/null +++ b/src/general-knowledge/skills/multi-channel/SKILL.md @@ -0,0 +1,64 @@ +--- +name: multi-channel +description: Setting up multiple platforms (Telegram + Discord) with proper general routing +--- + +## Multi-Channel Setup + +When using multiple platforms (e.g. Telegram + Discord), use `channels:` array instead of single `channel:`: + +```yaml +channels: + - id: discord + type: discord + mode: topic + bot_token_env: AGEND_DISCORD_TOKEN + group_id: "123456789" + options: + general_channel_id: "987654321" + - id: telegram + type: telegram + mode: topic + bot_token_env: AGEND_BOT_TOKEN + group_id: "-1001234567890" +``` + +**Critical: each adapter needs its own general instance.** + +```yaml +instances: + general: + working_directory: ~/.agend/general + topic_id: "987654321" # DC general channel + general_topic: true + channel_id: discord # binds to discord adapter + general-telegram: + working_directory: ~/.agend/general-telegram + topic_id: 1 # TG general topic (thread_id 1) + general_topic: true + channel_id: telegram # binds to telegram adapter +``` + +## Auto-Detection + +On startup, AgEnD checks each adapter has a bound general. If missing, it auto-creates one with a warning log: +``` +WARN: No general instance for adapter — auto-creating +``` + +The `channel_id` field explicitly binds a general to an adapter. Without it, matching falls back to instance name containing the adapter id (e.g. `general-telegram` matches `telegram`). + +## Common Mistakes + +| Mistake | Symptom | Fix | +|---------|---------|-----| +| One general for two platforms | TG messages reply in DC | Add a second general with `channel_id` | +| `channels:` without unique `id` per entry | Adapter collision | Set `id: discord` / `id: telegram` | +| Using `channel:` (singular) for multi-platform | Second platform ignored | Switch to `channels:` array | + +## Single → Multi Migration + +1. Change `channel:` to `channels:` array, add `id` to existing entry +2. Add new platform entry with its own `id` +3. Add a general instance for the new platform with `channel_id: ` +4. Restart fleet: `agend fleet restart` diff --git a/src/general-knowledge/skills/session-management/SKILL.md b/src/general-knowledge/skills/session-management/SKILL.md new file mode 100644 index 00000000..92ec919a --- /dev/null +++ b/src/general-knowledge/skills/session-management/SKILL.md @@ -0,0 +1,78 @@ +--- +name: session-management +description: Save/load/fork sessions, batch backup, reviewer session setup, kiro-cli session paths +--- + +## kiro-cli Session Storage + +kiro-cli automatically stores conversation sessions at: +- **Path:** `~/.kiro/sessions/cli/.json` +- Each session is a JSON file with the full conversation history +- Sessions persist across restarts — kiro-cli auto-resumes the latest session for each working directory + +**Useful for:** +- Manual backup: `cp ~/.kiro/sessions/cli/*.json ~/backup/` +- Finding a specific session: `ls -lt ~/.kiro/sessions/cli/ | head -5` +- Loading a session into a new instance via `pre_task_command: "/chat load "` + +## Reviewer Session Management + +For reviewer instances using kiro-cli: +- Recommend setting `pre_task_command: "/chat load reviewer-base.json"` in fleet.yaml +- This loads a base session with review guidelines on every restart +- Help user create the base session: + 1. Attach to reviewer: `agend attach ` + 2. Set up review context and guidelines + 3. Save: `/chat save reviewer-base.json -f` + 4. Add to fleet.yaml under the reviewer instance config: + ```yaml + instances: + reviewer-xxx: + pre_task_command: "/chat load reviewer-base.json" + ``` + +## Fork Instance (Session Cloning) + +When user wants to fork/clone an instance's session to a new instance: + +Steps: +1. Wait for source instance to be idle (check with tmux capture-pane, look for "X% !>" prompt) + +2. Save current session on source instance via tmux: + - `execute_bash`: `tmux send-keys -t agend: '/chat save YYYYMMDD.json -f' Enter` + - Wait a few seconds for save to complete + +3. Create new instance: + - `create_instance` with same backend and working_directory (or new one) + +4. Copy session file to new instance workspace: + - `execute_bash`: `cp ~/.agend/workspaces//YYYYMMDD.json ~/.agend/workspaces//` + +5. Wait for new instance to be idle, then load session via tmux: + - `execute_bash`: `tmux send-keys -t agend: '/chat load YYYYMMDD.json' Enter` + - Or configure `pre_task_command: "/chat load YYYYMMDD.json"` for auto-load on restart + +## Batch Session Backup + +Save all instances' sessions to a dated backup directory: + +```bash +DATE=$(date +%Y%m%d) +BACKUP_DIR="$HOME/.agend/session-backups/$DATE" +mkdir -p "$BACKUP_DIR" +MY_NAME="" # skip yourself to avoid paste collision +for win in $(tmux list-windows -t agend -F '#{window_name}' | grep -v bash); do + if [ "$win" = "$MY_NAME" ]; then continue; fi + tmux send-keys -t "agend:$win" "/chat save $BACKUP_DIR/${win}.json -f" Enter + sleep 3 +done +``` + +Important: +- Skip your own instance (the one executing this) to avoid paste collision +- Use `sleep 3` between saves +- Run fleet health check first — only backup idle instances +- Do NOT backup while instances are busy + +Restore a single instance: +- `tmux send-keys -t agend: '/chat load /path/to/backup.json' Enter` diff --git a/src/general-knowledge/steering/core-rules.md b/src/general-knowledge/steering/core-rules.md new file mode 100644 index 00000000..a295afc3 --- /dev/null +++ b/src/general-knowledge/steering/core-rules.md @@ -0,0 +1,85 @@ +# Core Rules + +> **These rules are mandatory for all general instances.** + +## Instance Creation Safety + +When creating a new instance with `create_instance`: + +**Pre-checks (mandatory):** +1. **Check for duplicate working directory** — Run `list_instances` and verify no existing instance uses the same `working_directory`. Two instances sharing a directory causes file conflicts and race conditions. +2. **Verify the directory exists** — If specifying a directory, confirm it exists on disk before calling create. +3. **Use unique names** — The instance name is derived from `topic_name` or `basename(directory)`. Avoid generic names like "dev" or "test" that may collide. + +**Post-checks (mandatory):** +4. **Confirm topic/channel creation** — After `create_instance` returns, verify the response includes a valid `topic_id`. This confirms the Discord channel or Telegram topic was actually created. +5. **Verify instance is running** — Use `describe_instance` to confirm the new instance reached "running" status. If it shows "stopped" or errors, check the output log. + +**Common mistakes to avoid:** +- Do NOT create an instance pointing to another instance's worktree path +- Do NOT reuse a `topic_name` that already exists (Discord will create a duplicate channel) +- Do NOT omit `topic_name` when `directory` is not provided — it will error + +## What NOT to Do (Dangerous Operations) + +- **Don't delete `~/.agend/fleet.yaml`** while fleet is running +- **Don't delete `~/.agend/fleet.pid`** manually — use `agend fleet stop` +- **Don't kill tmux server** (`tmux kill-server`) — kills all agent sessions +- **Don't edit instance output.log** — it's actively written by the daemon +- **Don't run two fleet processes** on the same AGEND_HOME — port/socket conflicts +- **Don't change `channel.group_id`** without re-creating all topics — routing breaks +- **Don't remove an instance from fleet.yaml** that has active work — stop it first + +## Access Mode Reference + +fleet.yaml `channel.access.mode` valid values: + +| Mode | Behavior | +|------|----------| +| `locked` | Only `allowed_users` can interact (default) | +| `pairing` | Users can request access via `/pair` command | +| `open` | All users can interact, no restrictions | + +Example: +```yaml +channel: + access: + mode: open # everyone can use + # mode: locked # whitelist only (add allowed_users) + # mode: pairing # users self-register via /pair + allowed_users: [123456789] # only needed for locked/pairing +``` + +**When to use each:** +- `locked` — production, private bot, security-sensitive +- `pairing` — semi-open, users request access with admin approval +- `open` — public demo, shared team bot, testing + +## Dangerous Working Directories + +NEVER create an instance with these working_directory values: +- `.` or `./` (current directory — pollutes global .kiro config) +- `~` or `/root` or `/home/user` (home directory) +- `/` (root filesystem) + +These will cause kiro-cli to write MCP config to the global ~/.kiro/ instead of the instance workspace, breaking ALL instances. + +Always use a dedicated subdirectory like `/home/user/projects/my-project` or let AgEnD auto-create workspace. + + +## Memory & Knowledge Management + +Use layered memory to minimize context usage: + +1. **Fleet Decision** — short, shared rules only (role, workflow rules, TODOs). Keep concise (~20 lines guideline). If it exceeds this, consider whether the details belong in soul.md instead. +2. **soul.md** (workspace root) — full memory: architecture, decisions, history. Loaded as steering. + - If soul.md doesn't exist, do NOT create one unprompted. Only create when the user explicitly asks. +3. **Skills** (`.kiro/skills/`) — reusable workflows. On-demand loading. + +Rules: +- Keep Fleet Decisions minimal. Move details to soul.md. +- Each instance maintains its own soul.md (not shared). +- Good workflows → **propose** converting to a skill. Create in `.kiro/skills//SKILL.md` only after user approval. +- Global skills (`src/general-knowledge/skills/`) only for knowledge ALL instances need. +- Never put architecture details or bug history in Fleet Decisions. +- After completing a **multi-step task that introduced new architectural knowledge or a reusable process**, suggest updating soul.md. Do NOT ask after routine tasks (reviews, single-file fixes, Q&A). diff --git a/src/instance-lifecycle.ts b/src/instance-lifecycle.ts index 44805b3b..8eba63c8 100644 --- a/src/instance-lifecycle.ts +++ b/src/instance-lifecycle.ts @@ -1,7 +1,7 @@ -import { existsSync, readFileSync, mkdirSync } from "node:fs"; -import { join, basename, dirname, resolve } from "node:path"; +import { existsSync, readFileSync, mkdirSync, realpathSync } from "node:fs"; +import { join, basename, dirname, resolve, sep as pathSep } from "node:path"; import { access, unlink } from "node:fs/promises"; -import { getAgendHome } from "./paths.js"; +import { getAgendHome, ensureWorkspaceGit } from "./paths.js"; import type { InstanceConfig, FleetConfig } from "./types.js"; import { DEFAULT_INSTANCE_CONFIG } from "./config.js"; import { sanitizeInstanceName } from "./topic-commands.js"; @@ -22,6 +22,7 @@ export interface LifecycleContext { readonly dataDir: string; readonly routing: RoutingEngine; readonly instanceIpcClients: Map; + readonly ipcStoppingInstances: Set; readonly sessionRegistry: Map; readonly eventLog: EventLog | null; readonly controlClient: TmuxControlClient | null; @@ -29,7 +30,7 @@ export interface LifecycleContext { getInstanceDir(name: string): string; saveFleetConfig(): void; connectIpcToInstance(name: string): Promise; - createForumTopic(topicName: string): Promise; + createForumTopic(topicName: string, adapterId?: string): Promise; deleteForumTopic(topicId: number | string): Promise; setTopicIcon(name: string, state: "green" | "blue" | "red" | "remove"): void; /** Remove instance with full cleanup (scheduler, IPC, routing, config). */ @@ -42,6 +43,7 @@ export interface LifecycleContext { webhookEmit(event: string, name: string, data?: Record): void; checkModelFailover(name: string, fiveHourPct: number): void; startStatuslineWatcher(name: string): void; + reactMessageStatus(chatId: string, messageId: string, emoji: string): void; } type Daemon = InstanceType; @@ -188,6 +190,13 @@ export class InstanceLifecycle { this.ctx.webhookEmit("pty_recovered", name, { downtime_s: data.downtime_s }); }, this.ctx.logger, `daemon.pty_recovered[${name}]`)); + daemon.on("message_queued", (data: { chatId: string; messageId: string }) => { + this.ctx.reactMessageStatus(data.chatId, data.messageId, "⏳"); + }); + daemon.on("message_delivered", (data: { chatId: string; messageId: string }) => { + this.ctx.reactMessageStatus(data.chatId, data.messageId, "✅"); + }); + this.ctx.setTopicIcon(name, "green"); this.ctx.touchActivity(name); } @@ -221,11 +230,13 @@ export class InstanceLifecycle { } // Clean up IPC client (prevents stale routing after stop) + this.ctx.ipcStoppingInstances.add(name); const ipc = this.ctx.instanceIpcClients.get(name); if (ipc) { try { ipc.close(); } catch { /* already closed */ } this.ctx.instanceIpcClients.delete(name); } + this.ctx.ipcStoppingInstances.delete(name); // Clean up session registry entries pointing to this instance for (const [session, instance] of this.ctx.sessionRegistry) { if (instance === name) this.ctx.sessionRegistry.delete(session); @@ -334,6 +345,7 @@ export class InstanceLifecycle { async handleCreate( args: LifecycleCreateArgs, respond: (result: unknown, error?: string) => void, + adapterId?: string, ): Promise { const rawDirectory = args.directory; const directory = rawDirectory ? rawDirectory.replace(/^~/, process.env.HOME || "~") : undefined; @@ -358,15 +370,29 @@ export class InstanceLifecycle { } } - // Enforce project_roots boundary when configured. - // Note: uses path.resolve() (string normalization), not fs.realpathSync(), - // so symlinks are not resolved — known limitation. + // Enforce project_roots boundary when configured. Use realpathSync so + // symlinks cannot be used to escape the allowed roots (a directory under + // an allowed root that symlinks to `/etc` would otherwise pass the string + // prefix check). const roots = this.ctx.fleetConfig?.project_roots; if (directory && roots?.length) { - const resolved = resolve(directory); + let resolved: string; + try { + resolved = realpathSync(resolve(directory)); + } catch { + respond(null, `Directory "${directory}" is not accessible`); + return; + } const allowed = roots.some(r => { - const root = resolve(r.replace(/^~/, process.env.HOME || "~")); - return resolved === root || resolved.startsWith(root + "/"); + const raw = resolve(r.replace(/^~/, process.env.HOME || "~")); + let root: string; + try { + root = realpathSync(raw); + } catch { + // Root doesn't exist on disk — cannot be a valid boundary. + return false; + } + return resolved === root || resolved.startsWith(root + pathSep); }); if (!allowed) { respond(null, `Directory "${directory}" is not under project_roots. Allowed: ${roots.join(", ")}`); @@ -463,7 +489,7 @@ export class InstanceLifecycle { let newInstanceName: string | undefined; try { - createdTopicId = await this.ctx.createForumTopic(topicName!); + createdTopicId = await this.ctx.createForumTopic(topicName!, adapterId); // Use explicit topic_name as name base when provided; fall back to directory basename const explicitTopicName = args.topic_name; @@ -474,6 +500,7 @@ export class InstanceLifecycle { if (!directory) { workDir = join(getAgendHome(), "workspaces", newInstanceName); mkdirSync(workDir, { recursive: true }); + ensureWorkspaceGit(workDir); } const instanceConfig = { diff --git a/src/instructions.ts b/src/instructions.ts index 33424253..3e658720 100644 --- a/src/instructions.ts +++ b/src/instructions.ts @@ -37,11 +37,17 @@ export function buildFleetInstructions(params: FleetInstructionsParams): string // MCP mode: inject MCP tool usage instructions sections.push([ "## Message Format", - "- `[user:name]` — from a Telegram/Discord user → reply with the `reply` tool.", + "- `[user:name via platform, id:USER_ID]` — from a Telegram/Discord user → reply with the `reply` tool.", "- `[from:instance-name]` — from another fleet instance → reply with `send_to_instance`, NOT the reply tool.", "", "**Always use the `reply` tool for ALL responses to users.** Do not respond directly in the terminal.", "", + "## Mentioning Users & Bots", + "- Discord: `<@USER_ID>` (e.g. `<@368442276000694273>`). Extract the id from the `id:` field in the message header.", + "- Telegram: `@username` (plain text).", + "- When notifying a specific user in a channel, include their mention in the reply text.", + "- To mention another bot in collab mode, use the same format with the bot's user ID.", + "", "## Tool Usage", "- reply: respond to users. react: emoji reactions. edit_message: update a sent message. download_attachment: fetch files.", "- If the inbound message has image_path, Read that file — it is a photo.", diff --git a/src/logger.ts b/src/logger.ts index d85e8a21..2d3bbde5 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -1,29 +1,35 @@ import pino from "pino"; import { join } from "node:path"; -import { mkdirSync, statSync, readFileSync, writeFileSync } from "node:fs"; +import { mkdirSync, statSync, existsSync, renameSync, unlinkSync, writeFileSync } from "node:fs"; import { getAgendHome } from "./paths.js"; const DATA_DIR = getAgendHome(); const LOG_FILE = join(DATA_DIR, "daemon.log"); const MAX_LOG_SIZE = 10 * 1024 * 1024; // 10 MB -const TRUNCATE_TO = 5 * 1024 * 1024; // keep last 5 MB +const ROTATE_MAX_FILES = 3; -/** Truncate log to tail when it exceeds MAX_LOG_SIZE (no rotation/backup files) */ -export function truncateLogIfNeeded(logPath: string): void { +/** Rotate a log file: foo.log → foo.log.1 → foo.log.2 → foo.log.3 (deleted) */ +export function rotateLogIfNeeded(logPath: string, maxSize = MAX_LOG_SIZE, maxFiles = ROTATE_MAX_FILES): void { try { + if (!existsSync(logPath)) return; const stat = statSync(logPath); - if (stat.size < MAX_LOG_SIZE) return; + if (stat.size < maxSize) return; - const buf = readFileSync(logPath); - const tail = buf.subarray(buf.length - TRUNCATE_TO); - const nl = tail.indexOf(0x0a); - writeFileSync(logPath, nl >= 0 ? tail.subarray(nl + 1) : tail); - } catch { /* file may not exist yet */ } + // Shift existing rotated files + for (let i = maxFiles; i >= 1; i--) { + const src = i === 1 ? logPath : `${logPath}.${i - 1}`; + const dst = `${logPath}.${i}`; + if (i === maxFiles) { try { unlinkSync(dst); } catch {} } + if (existsSync(src)) { try { renameSync(src, dst); } catch {} } + } + // Truncate current log + writeFileSync(logPath, ""); + } catch { /* best effort */ } } export function createLogger(level: string = "info") { mkdirSync(DATA_DIR, { recursive: true }); - truncateLogIfNeeded(LOG_FILE); + rotateLogIfNeeded(LOG_FILE); return pino({ level, transport: { diff --git a/src/outbound-handlers.ts b/src/outbound-handlers.ts index e374ae22..3ebf5f7a 100644 --- a/src/outbound-handlers.ts +++ b/src/outbound-handlers.ts @@ -23,6 +23,7 @@ import { ReplaceInstanceArgs, ReportResultArgs, RequestInformationArgs, + RestartInstanceArgs, SendToInstanceArgs, StartInstanceArgs, TeardownDeploymentArgs, @@ -34,17 +35,24 @@ import { export interface OutboundContext { readonly fleetConfig: FleetConfig | null; readonly adapter: ChannelAdapter | null; + readonly adapters?: Map; readonly logger: Logger; readonly routing: RoutingEngine; readonly instanceIpcClients: Map; readonly lifecycle: InstanceLifecycle; readonly sessionRegistry: Map; readonly eventLog: EventLog | null; + readonly classicChannels: { getAll(): { instanceName: string; name: string; backend?: string; channelId: string }[] } | null; lastActivityMs(name: string): number; startInstance(name: string, config: InstanceConfig, topicMode: boolean): Promise; + restartSingleInstance(name: string): Promise; connectIpcToInstance(name: string): Promise; saveFleetConfig(): void; queueMirrorMessage?(text: string): void; + getAdapterForInstance?(name: string): ChannelAdapter | null; + getChannelConfig?(adapterId?: string): import("./types.js").ChannelConfig | undefined; + getGroupIdForInstance?(name: string): string; + getWorldForInstance?(name: string): { id: string; adapter: ChannelAdapter } | undefined; } /** Metadata extracted from the raw outbound message. */ @@ -143,11 +151,13 @@ const sendToInstance: Handler = (ctx, rawArgs, respond, meta) => { const targetTopicId = targetInstance?.topic_id; const targetIsGeneral = targetInstance?.general_topic === true; if (targetTopicId && !targetIsGeneral && !ctx.sessionRegistry.has(targetName)) { + const targetAdapter = ctx.getAdapterForInstance?.(targetInstanceName) ?? ctx.adapter; + const targetGroupId = ctx.getGroupIdForInstance?.(targetInstanceName) ?? String(groupId); const showFull = requestKind === "task" || requestKind === "query"; const text = showFull ? `${notificationLabel}:\n${message}` : `${notificationLabel}: ${ipcMeta.task_summary ?? `${message.slice(0, 100)}${message.length > 100 ? "…" : ""}`}`; - ctx.adapter.sendText(String(groupId), text, { threadId: String(targetTopicId) }) + targetAdapter!.sendText(String(targetGroupId), text, { threadId: String(targetTopicId) }) .catch(e => ctx.logger.warn({ err: e }, "Failed to post target topic notification")); } } @@ -157,7 +167,9 @@ const sendToInstance: Handler = (ctx, rawArgs, respond, meta) => { const senderTopicId = senderInstance?.topic_id; const senderIsGeneral = senderInstance?.general_topic === true; if (senderTopicId && !senderIsGeneral) { - ctx.adapter.sendText(String(groupId), `${notificationLabel}:\n${message}`, { threadId: String(senderTopicId) }) + const senderAdapter = ctx.getAdapterForInstance?.(meta.instanceName) ?? ctx.adapter; + const senderGroupId = ctx.getGroupIdForInstance?.(meta.instanceName) ?? String(groupId); + senderAdapter!.sendText(senderGroupId, `${notificationLabel}:\n${message}`, { threadId: String(senderTopicId) }) .catch(e => ctx.logger.warn({ err: e }, "Failed to post sender topic notification")); } } @@ -195,6 +207,25 @@ const listInstances: Handler = (ctx, rawArgs, respond, meta) => { if (filterTags?.length) { allInstances = allInstances.filter(i => i.tags.some(t => filterTags.includes(t))); } + // Append classic bot instances + if (ctx.classicChannels && !filterTags?.length) { + const fleetNames = new Set(Object.keys(ctx.fleetConfig?.instances ?? {})); + for (const ch of ctx.classicChannels.getAll()) { + if (ch.instanceName === meta.instanceName || fleetNames.has(ch.instanceName)) continue; + allInstances.push({ + name: ch.instanceName, + type: "instance" as const, + status: ctx.lifecycle.daemons.has(ch.instanceName) ? "running" : "stopped", + working_directory: "", + topic_id: ch.channelId as any, + display_name: `classic: ${ch.name}`, + description: `ClassicBot channel (${ch.name})`, + backend: ch.backend ?? "claude-code", + tags: ["classic"], + last_activity: ctx.lastActivityMs(ch.instanceName) ? new Date(ctx.lastActivityMs(ch.instanceName)).toISOString() : null, + }); + } + } const externalSessions = [...ctx.sessionRegistry.entries()] .filter(([sessName]) => sessName !== senderLabel) .map(([sessName, hostInstance]) => ({ name: sessName, type: "session" as const, host: hostInstance })); @@ -252,6 +283,18 @@ const startInstance: Handler = async (ctx, rawArgs, respond) => { } }; +const restartInstance: Handler = async (ctx, rawArgs, respond) => { + const v = validateArgs(RestartInstanceArgs, rawArgs, "restart_instance"); + if (!v.ok) { respond(null, v.error); return; } + const targetName = v.data.name; + try { + await ctx.restartSingleInstance(targetName); + respond({ success: true, status: "restarted" }); + } catch (err) { + respond(null, `Failed to restart instance '${targetName}': ${sanitizeError(err, ctx, `restart_instance(${targetName})`)}`); + } +}; + /** Wrap send_to_instance with pre-filled metadata fields. */ function wrapAsSend( schema: z.ZodType, @@ -318,10 +361,21 @@ const reportResult = wrapAsSend( }, ); -const createInstance: Handler = async (ctx, rawArgs, respond) => { +const createInstance: Handler = async (ctx, rawArgs, respond, meta) => { const v = validateArgs(CreateInstanceArgs, rawArgs, "create_instance"); if (!v.ok) { respond(null, v.error); return; } - await ctx.lifecycle.handleCreate(v.data, respond); + // Reject dangerous working directories + const dir = v.data.directory; + if (dir) { + const resolved = dir.replace(/^~/, process.env.HOME || "/root").replace(/\/+$/, ""); + const dangerous = [".", "./", "/", process.env.HOME || "/root", "/root"]; + if (dangerous.includes(resolved) || resolved === "") { + respond(null, `⛔ Dangerous working_directory "${dir}" — would pollute global config. Use a dedicated subdirectory.`); + return; + } + } + const callerAdapterId = meta?.instanceName ? ctx.getWorldForInstance?.(meta.instanceName)?.id : undefined; + await ctx.lifecycle.handleCreate(v.data, respond, callerAdapterId); }; const deleteInstance: Handler = async (ctx, rawArgs, respond, meta) => { @@ -719,6 +773,7 @@ export const outboundHandlers = new Map([ ["report_result", reportResult], ["describe_instance", describeInstance], ["start_instance", startInstance], + ["restart_instance", restartInstance], ["create_instance", createInstance], ["delete_instance", deleteInstance], ["replace_instance", replaceInstance], diff --git a/src/outbound-schemas.ts b/src/outbound-schemas.ts index 7e6bc5d3..350e5c31 100644 --- a/src/outbound-schemas.ts +++ b/src/outbound-schemas.ts @@ -155,6 +155,10 @@ export const StartInstanceArgs = z.object({ name: NonEmptyString.describe("The instance name to start (from list_instances)"), }); +export const RestartInstanceArgs = z.object({ + name: NonEmptyString.describe("The instance name to restart"), +}); + export const DeleteInstanceArgs = z.object({ name: NonEmptyString.describe("The instance name to delete (from list_instances)"), delete_topic: z.boolean().optional() @@ -183,7 +187,7 @@ export const CreateInstanceArgs = z.object({ model: z.string().optional().describe( "Model to use. Claude: sonnet, opus, haiku, opusplan, best, sonnet[1m], opus[1m]. Codex: gpt-4o, o3. Gemini: gemini-2.5-pro. Omit for default.", ), - backend: z.enum(["claude-code", "gemini-cli", "codex", "opencode", "kiro-cli"]).optional() + backend: z.enum(["claude-code", "gemini-cli", "codex", "opencode", "kiro-cli", "antigravity"]).optional() .describe("CLI backend to use. Defaults to claude-code."), branch: z.string().optional().describe( "Git branch name. When specified, creates a git worktree from the directory's repo and uses it as the working directory. If the branch doesn't exist, it will be created.", diff --git a/src/paths.ts b/src/paths.ts index bcecc6bd..79dd398a 100644 --- a/src/paths.ts +++ b/src/paths.ts @@ -1,6 +1,8 @@ import { join } from "node:path"; import { homedir } from "node:os"; import { createHash } from "node:crypto"; +import { existsSync } from "node:fs"; +import { execSync } from "node:child_process"; /** Resolve the AgEnD data directory. Override with AGEND_HOME env var. */ export function getAgendHome(): string { @@ -12,7 +14,14 @@ export function getTmuxSessionName(): string { const home = getAgendHome(); const defaultHome = join(homedir(), ".agend"); if (home === defaultHome) return "agend"; - return "agend-" + createHash("md5").update(home).digest("hex").slice(0, 6); + // sha256 instead of md5: this hash is not security-critical (we just need a + // short stable suffix so two custom AGEND_HOME values don't collide on the + // tmux session/socket namespace), but md5 trips FIPS-mode Node and security + // scanners. The suffix value WILL change for users with a custom + // AGEND_HOME — that only affects an in-memory tmux session/socket name, so + // a single daemon restart resyncs cleanly (any orphan tmux session under + // the old name can be killed manually). + return "agend-" + createHash("sha256").update(home).digest("hex").slice(0, 6); } /** @@ -24,5 +33,19 @@ export function getTmuxSocketName(): string | null { const home = getAgendHome(); const defaultHome = join(homedir(), ".agend"); if (home === defaultHome) return null; - return "agend-" + createHash("md5").update(home).digest("hex").slice(0, 6); + // sha256 instead of md5: this hash is not security-critical (we just need a + // short stable suffix so two custom AGEND_HOME values don't collide on the + // tmux session/socket namespace), but md5 trips FIPS-mode Node and security + // scanners. The suffix value WILL change for users with a custom + // AGEND_HOME — that only affects an in-memory tmux session/socket name, so + // a single daemon restart resyncs cleanly (any orphan tmux session under + // the old name can be killed manually). + return "agend-" + createHash("sha256").update(home).digest("hex").slice(0, 6); +} + +/** Ensure an auto-created workspace has a .git directory (best effort). */ +export function ensureWorkspaceGit(dir: string): void { + if (!existsSync(join(dir, ".git"))) { + try { execSync(`git init "${dir}"`, { stdio: "ignore" }); } catch { /* best effort */ } + } } diff --git a/src/quickstart.ts b/src/quickstart.ts index 3b3ab33b..1762877b 100644 --- a/src/quickstart.ts +++ b/src/quickstart.ts @@ -1,9 +1,10 @@ import { createInterface } from "node:readline/promises"; -import { writeFileSync, mkdirSync, existsSync, readdirSync, statSync } from "node:fs"; +import { writeFileSync, readFileSync, mkdirSync, existsSync, readdirSync, statSync, chmodSync } from "node:fs"; import { join, resolve } from "node:path"; import { homedir, platform } from "node:os"; import { stdin, stdout } from "node:process"; import { execSync } from "node:child_process"; +import yaml from "js-yaml"; import { BACKENDS, validateBotToken, verifyBotToken } from "./setup-wizard.js"; import { getAgendHome } from "./paths.js"; @@ -119,6 +120,225 @@ function detectProjectRoots(): { path: string; gitCount: number }[] { return results; } +// ── ClassicBot guild append helper ─────────────────────── + +const CLASSIC_BOT_PATH = join(DATA_DIR, "classicBot.yaml"); + +async function maybeUpdateClassicBot(rl: import("node:readline/promises").Interface): Promise { + if (!existsSync(CLASSIC_BOT_PATH)) return; + const config = yaml.load(readFileSync(CLASSIC_BOT_PATH, "utf-8")) as Record; + let changed = false; + + // ── Add allowed guilds ── + const addGuilds = await rl.question(`\n classicBot.yaml found. Add allowed guilds? [y/N] `); + if (addGuilds.toLowerCase() === "y") { + const guilds: string[] = ((config as any)?.defaults?.allowed_guilds ?? []).map(String); + console.log(` Current allowed guilds: ${guilds.join(", ") || "(none)"}`); + + const token = readDiscordToken(); + if (token) { + const available = await listDiscordGuilds(token); + if (available.length > 0) { + const unregistered = available.filter(g => !guilds.includes(g.id)); + if (unregistered.length > 0) { + console.log(`\n Bot is in these servers:`); + for (let i = 0; i < unregistered.length; i++) { + console.log(` ${i + 1}. ${unregistered[i].name} ${dim(`(${unregistered[i].id})`)}`); + } + console.log(` 0. Skip`); + while (true) { + const choice = (await rl.question(" Add server [0]: ")).trim(); + if (!choice || choice === "0") break; + const idx = parseInt(choice, 10) - 1; + if (idx >= 0 && idx < unregistered.length) { + guilds.push(unregistered[idx].id); + console.log(` ${green("✓")} Added: ${unregistered[idx].name} (${unregistered[idx].id})`); + unregistered.splice(idx, 1); + if (unregistered.length === 0) break; + } + } + } else { + console.log(` All servers already in allowed list.`); + } + } + } + while (true) { + const gid = (await rl.question(" Add guild ID manually (Enter to finish): ")).trim(); + if (!gid) break; + if (guilds.includes(gid)) { console.log(` Already in list.`); continue; } + guilds.push(gid); + console.log(` ${green("✓")} Added: ${gid}`); + } + ((config as any).defaults ??= {}).allowed_guilds = guilds; + changed = true; + } + + // ── Add admin users ── + const addAdmins = await rl.question(` Add admin users? [y/N] `); + if (addAdmins.toLowerCase() === "y") { + const admins: string[] = ((config as any)?.defaults?.admin_users ?? []).map(String); + console.log(` Current admin users: ${admins.join(", ") || "(none)"}`); + while (true) { + const uid = (await rl.question(" Add user ID (Enter to finish): ")).trim(); + if (!uid) break; + if (admins.includes(uid)) { console.log(` Already in list.`); continue; } + admins.push(uid); + console.log(` ${green("✓")} Added: ${uid}`); + } + ((config as any).defaults ??= {}).admin_users = admins; + changed = true; + } + + if (changed) { + writeFileSync(CLASSIC_BOT_PATH, `# ClassicBot Configuration\n${yaml.dump(config, { quotingType: '"', forceQuotes: false })}`); + console.log(` ${green("✓")} Updated ${CLASSIC_BOT_PATH}`); + } +} + +/** Read Discord bot token from .env (uses bot_token_env from fleet.yaml) */ +function readDiscordToken(): string | null { + if (!existsSync(ENV_PATH)) return null; + const content = readFileSync(ENV_PATH, "utf-8"); + // Try bot_token_env from fleet.yaml first + if (existsSync(FLEET_CONFIG_PATH)) { + try { + const fleet = yaml.load(readFileSync(FLEET_CONFIG_PATH, "utf-8")) as Record; + const envName = fleet?.channel?.bot_token_env; + if (envName) { + const m = content.match(new RegExp(`^${envName}=(\\S+)`, "m")); + if (m) return m[1]; + } + } catch { /* ignore */ } + } + // Fallback: common Discord token env var names + const match = content.match(/^(?:AGEND_DISCORD_TOKEN|DISCORD_TOKEN)=(\S+)/m); + return match?.[1] ?? null; +} + +// ── Platform flow results ──────────────────────────────── + +interface PlatformResult { + type: "telegram" | "discord"; + token: string; + tokenEnvName: string; + botUsername: string; + groupId: string; + userId: string; + generalChannelId?: string; +} + +async function runTelegramFlow(rl: import("node:readline/promises").Interface): Promise { + console.log(bold("Telegram Bot")); + console.log(` 1. Open BotFather: ${dim("https://t.me/BotFather")}`); + console.log(` 2. Send /newbot and pick a name`); + console.log(` 3. Copy the token\n`); + + let token = ""; + let botUsername = ""; + const tokenEnvName = "AGEND_BOT_TOKEN"; + while (true) { + token = (await rl.question(" Paste token: ")).trim(); + if (!validateBotToken(token)) { + console.log(` ${yellow("Invalid format.")} Should look like: 123456789:ABCdef...`); + continue; + } + const result = await verifyBotToken(token); + if (!result.valid) { + console.log(` ${yellow("Token rejected by Telegram.")} Try again.`); + continue; + } + botUsername = result.username ?? ""; + console.log(` ${green("✓")} Bot verified: @${botUsername}\n`); + break; + } + + console.log(` Add @${botUsername} to a Telegram group, then send /start in the group.\n`); + const detected = await detectGroupAndUser(token); + const groupId = String(detected.groupId); + const userId = String(detected.userId); + console.log(` ${green("✓")} Group: ${groupId} | User: ${userId}\n`); + + return { type: "telegram", token, tokenEnvName, botUsername, groupId, userId }; +} + +async function runDiscordFlow(rl: import("node:readline/promises").Interface): Promise { + console.log(bold("Discord Bot")); + console.log(` 1. Go to Discord Developer Portal: ${dim("https://discord.com/developers/applications")}`); + console.log(` 2. New Application → Bot → Reset Token → Copy`); + console.log(` 3. Enable ${bold("Message Content Intent")} under Bot → Privileged Gateway Intents\n`); + + let token = ""; + let botUsername = ""; + const tokenEnvName = "AGEND_DISCORD_TOKEN"; + while (true) { + token = (await rl.question(" Paste bot token: ")).trim(); + if (!token) continue; + const result = await verifyDiscordToken(token); + if (!result.valid) { + console.log(` ${yellow("Token rejected by Discord.")} Try again.`); + continue; + } + botUsername = result.username ?? ""; + console.log(` ${green("✓")} Bot verified: ${botUsername}\n`); + break; + } + + let groupId = ""; + const guilds = await listDiscordGuilds(token); + if (guilds.length === 0) { + console.log(` Bot is not in any server. Invite it first:`); + console.log(` ${dim("https://discord.com/developers/applications → OAuth2 → URL Generator")}`); + console.log(` Scopes: bot | Permissions: Send Messages, Read Message History, Manage Channels\n`); + groupId = (await rl.question(" Paste Guild ID: ")).trim(); + } else if (guilds.length === 1) { + groupId = guilds[0].id; + console.log(` ${green("✓")} Guild: ${guilds[0].name} (${groupId})`); + } else { + console.log(" Bot is in multiple servers:"); + for (let i = 0; i < guilds.length; i++) { + console.log(` ${i + 1}. ${guilds[i].name} ${dim(`(${guilds[i].id})`)}`); + } + const gChoice = await rl.question(` Choose [1]: `); + const gIdx = Math.max(0, Math.min(guilds.length - 1, parseInt(gChoice || "1", 10) - 1)); + groupId = guilds[gIdx].id; + console.log(` ${green("✓")} Guild: ${guilds[gIdx].name}`); + } + + console.log(`\n To get your User ID:`); + console.log(` Discord Settings → Advanced → ${bold("Developer Mode")} ON → Right-click yourself → Copy User ID\n`); + const userId = (await rl.question(" Paste your User ID: ")).trim(); + console.log(` ${green("✓")} User: ${userId}\n`); + + console.log(`\n To get a Channel ID for the General channel:`); + console.log(` Right-click the text channel → ${bold("Copy Channel ID")}\n`); + let generalChannelId = ""; + while (!generalChannelId) { + generalChannelId = (await rl.question(" Paste General Channel ID (required): ")).trim(); + if (!generalChannelId) { + console.log(` ${yellow("⚠")} General Channel ID is required for Discord. The General dispatcher needs a channel to operate in.`); + } + } + console.log(` ${green("✓")} General Channel: ${generalChannelId}\n`); + + return { type: "discord", token, tokenEnvName, botUsername, groupId, userId, generalChannelId: generalChannelId || undefined }; +} + +/** Build a channel config object from a PlatformResult */ +function buildChannelConfig(p: PlatformResult): Record { + const cfg: Record = { + id: p.type, + type: p.type, + mode: "topic", + bot_token_env: p.tokenEnvName, + group_id: p.groupId, + access: { mode: "locked", allowed_users: [p.userId] }, + }; + if (p.generalChannelId) { + cfg.options = { general_channel_id: p.generalChannelId }; + } + return cfg; +} + // ── Main ───────────────────────────────────────────────── export async function runQuickstart(): Promise { @@ -127,21 +347,134 @@ export async function runQuickstart(): Promise { try { console.log(`\n${bold("═══ AgEnD Quickstart ═══")}\n`); - // Check fleet.pid conflict - if (existsSync(join(DATA_DIR, "fleet.pid"))) { - console.error("Fleet is already running. Stop it first: agend fleet stop"); - process.exit(1); - } - // Check existing config if (existsSync(FLEET_CONFIG_PATH)) { - const overwrite = await rl.question( - ` ${yellow("fleet.yaml already exists.")} Overwrite? [y/N] `, - ); - if (overwrite.toLowerCase() !== "y") { - console.log(" Aborted."); + console.log(` ${yellow("fleet.yaml already exists.")} What would you like to do?`); + console.log(" 1. Add allowed users"); + console.log(" 2. Add another platform"); + console.log(" 3. Overwrite (start fresh)"); + console.log(" 4. Skip"); + const action = (await rl.question(" Choose [4]: ")).trim(); + + if (action === "1") { + // ── Add allowed users to existing fleet.yaml ── + const raw = readFileSync(FLEET_CONFIG_PATH, "utf-8"); + const config = yaml.load(raw) as Record; + const currentUsers: string[] = (config as any)?.channel?.access?.allowed_users ?? []; + console.log(`\n Current allowed users: ${currentUsers.join(", ") || "(none)"}`); + while (true) { + const uid = (await rl.question(" Add user ID (Enter to finish): ")).trim(); + if (!uid) break; + currentUsers.push(uid); + console.log(` ${green("✓")} Added: ${uid}`); + } + ((config as any).channel ??= {}).access ??= { mode: "locked" }; + (config as any).channel.access.allowed_users = currentUsers; + writeFileSync(FLEET_CONFIG_PATH, yaml.dump(config, { quotingType: '"', forceQuotes: false })); + console.log(` ${green("✓")} Updated ${FLEET_CONFIG_PATH}`); + + // Hot-reload if fleet is running + const pidPathUsers = join(DATA_DIR, "fleet.pid"); + if (existsSync(pidPathUsers)) { + try { process.kill(parseInt(readFileSync(pidPathUsers, "utf-8").trim(), 10), "SIGHUP"); console.log(` ${green("✓")} Fleet hot-reloaded.`); } catch { /* not running */ } + } + + await maybeUpdateClassicBot(rl); + console.log(`\n${bold("═══ Done ═══")}\n`); + return; + } + + if (action === "2") { + // ── Add another platform ── + const raw = readFileSync(FLEET_CONFIG_PATH, "utf-8"); + const config = yaml.load(raw) as Record; + + // Normalize channel → channels + if (config.channel && !config.channels) { + config.channels = [{ ...config.channel, id: config.channel.type }]; + delete config.channel; + } + const channels: any[] = config.channels ?? []; + const existingTypes = channels.map((c: any) => c.type); + + const available = ["telegram", "discord"].filter(t => !existingTypes.includes(t)); + if (available.length === 0) { + console.log(` Both platforms already configured.`); + await maybeUpdateClassicBot(rl); + console.log(`\n${bold("═══ Done ═══")}\n`); + return; + } + + let platformType: string; + if (available.length === 1) { + platformType = available[0]; + console.log(`\n Adding: ${platformType}\n`); + } else { + console.log(`\n Available platforms:`); + for (let i = 0; i < available.length; i++) { + console.log(` ${i + 1}. ${available[i]}`); + } + const pChoice = (await rl.question(" Choose [1]: ")).trim(); + platformType = available[Math.max(0, Math.min(available.length - 1, parseInt(pChoice || "1", 10) - 1))]; + } + + console.log(); + const result = platformType === "telegram" ? await runTelegramFlow(rl) : await runDiscordFlow(rl); + channels.push(buildChannelConfig(result)); + config.channels = channels; + + writeFileSync(FLEET_CONFIG_PATH, yaml.dump(config, { quotingType: '"', forceQuotes: false })); + console.log(` ${green("✓")} Updated ${FLEET_CONFIG_PATH}`); + + // Append or replace token in .env + const envLine = `${result.tokenEnvName}=${result.token}`; + const existingEnv = existsSync(ENV_PATH) ? readFileSync(ENV_PATH, "utf-8") : ""; + const lines = existingEnv.split("\n"); + const idx = lines.findIndex(l => l.startsWith(result.tokenEnvName + "=")); + if (idx >= 0) lines[idx] = envLine; + else lines.push(envLine); + writeFileSync(ENV_PATH, lines.filter(l => l !== "").join("\n") + "\n", { mode: 0o600 }); + try { chmodSync(ENV_PATH, 0o600); } catch {} + console.log(` ${green("✓")} ${ENV_PATH}`); + + await maybeUpdateClassicBot(rl); + + // Auto-restart fleet if running — prefer systemd to keep watchdog/crash recovery + const pidPath = join(DATA_DIR, "fleet.pid"); + if (existsSync(pidPath)) { + const pid = parseInt(readFileSync(pidPath, "utf-8").trim(), 10); + try { + process.kill(pid, 0); // check alive + console.log(`\n Fleet is running. Restarting to apply new channel...`); + const { execSync, spawn } = await import("node:child_process"); + let usedSystemd = false; + try { + execSync("systemctl --user is-active com.agend.fleet", { stdio: "pipe" }); + execSync("systemctl --user restart com.agend.fleet", { stdio: "pipe" }); + usedSystemd = true; + console.log(` ${green("✓")} Fleet restarted via systemd.`); + } catch { + const child = spawn("sh", ["-c", "sleep 2 && agend fleet restart --reload"], { detached: true, stdio: "ignore" }); + child.unref(); + console.log(` ${green("✓")} Fleet restart scheduled (2s). New platform will be available shortly.`); + } + } catch { /* not running, user will start manually */ } + } + + console.log(`\n${bold("═══ Done ═══")}\n`); + return; + } + + if (action !== "3") { + // Skip (default) + await maybeUpdateClassicBot(rl); + console.log(`\n${bold("═══ Done ═══")}\n`); return; } + // action === "3" → overwrite requires fleet to be stopped + if (existsSync(join(DATA_DIR, "fleet.pid"))) { + try { process.kill(parseInt(readFileSync(join(DATA_DIR, "fleet.pid"), "utf-8").trim(), 10), 0); console.error("Fleet is running. Stop it first: agend stop"); process.exit(1); } catch { /* stale pid, ok to proceed */ } + } } // Check tmux @@ -181,100 +514,22 @@ export async function runQuickstart(): Promise { console.log(`\n${bold("Step 2/4: Channel")}`); console.log(" 1. Telegram"); console.log(" 2. Discord"); + console.log(" 3. Both (Telegram + Discord)"); const chChoice = await rl.question(` Choose [1]: `); - const channel = chChoice.trim() === "2" ? "discord" : "telegram"; - console.log(` ${green("✓")} ${channel}\n`); - - let token = ""; - let botUsername = ""; - let groupId = ""; - let userId = ""; - let tokenEnvName = ""; - - if (channel === "telegram") { - // ── Telegram flow ────────────────────────────────── - - console.log(bold("Step 3/4: Telegram Bot")); - console.log(` 1. Open BotFather: ${dim("https://t.me/BotFather")}`); - console.log(` 2. Send /newbot and pick a name`); - console.log(` 3. Copy the token\n`); - - tokenEnvName = "AGEND_BOT_TOKEN"; - while (true) { - token = (await rl.question(" Paste token: ")).trim(); - if (!validateBotToken(token)) { - console.log(` ${yellow("Invalid format.")} Should look like: 123456789:ABCdef...`); - continue; - } - const result = await verifyBotToken(token); - if (!result.valid) { - console.log(` ${yellow("Token rejected by Telegram.")} Try again.`); - continue; - } - botUsername = result.username ?? ""; - console.log(` ${green("✓")} Bot verified: @${botUsername}\n`); - break; - } - - console.log(bold("Step 4/4: Group & User ID")); - console.log(` Add @${botUsername} to a Telegram group, then send /start in the group.\n`); - - const detected = await detectGroupAndUser(token); - groupId = String(detected.groupId); - userId = String(detected.userId); - console.log(` ${green("✓")} Group: ${groupId} | User: ${userId}\n`); + const channelChoice = chChoice.trim() === "3" ? "both" : chChoice.trim() === "2" ? "discord" : "telegram"; + console.log(` ${green("✓")} ${channelChoice}\n`); - } else { - // ── Discord flow ─────────────────────────────────── - - console.log(bold("Step 3/4: Discord Bot")); - console.log(` 1. Go to Discord Developer Portal: ${dim("https://discord.com/developers/applications")}`); - console.log(` 2. New Application → Bot → Reset Token → Copy`); - console.log(` 3. Enable ${bold("Message Content Intent")} under Bot → Privileged Gateway Intents\n`); - - tokenEnvName = "AGEND_DISCORD_TOKEN"; - while (true) { - token = (await rl.question(" Paste bot token: ")).trim(); - if (!token) continue; - const result = await verifyDiscordToken(token); - if (!result.valid) { - console.log(` ${yellow("Token rejected by Discord.")} Try again.`); - continue; - } - botUsername = result.username ?? ""; - console.log(` ${green("✓")} Bot verified: ${botUsername}\n`); - break; - } - - console.log(bold("Step 4/4: Guild & User ID")); - - // Auto-detect guilds - const guilds = await listDiscordGuilds(token); - if (guilds.length === 0) { - console.log(` Bot is not in any server. Invite it first:`); - console.log(` ${dim("https://discord.com/developers/applications → OAuth2 → URL Generator")}`); - console.log(` Scopes: bot | Permissions: Send Messages, Read Message History, Manage Channels\n`); - groupId = (await rl.question(" Paste Guild ID: ")).trim(); - } else if (guilds.length === 1) { - groupId = guilds[0].id; - console.log(` ${green("✓")} Guild: ${guilds[0].name} (${groupId})`); - } else { - console.log(" Bot is in multiple servers:"); - for (let i = 0; i < guilds.length; i++) { - console.log(` ${i + 1}. ${guilds[i].name} ${dim(`(${guilds[i].id})`)}`); - } - const gChoice = await rl.question(` Choose [1]: `); - const gIdx = Math.max(0, Math.min(guilds.length - 1, parseInt(gChoice || "1", 10) - 1)); - groupId = guilds[gIdx].id; - console.log(` ${green("✓")} Guild: ${guilds[gIdx].name}`); - } - - console.log(`\n To get your User ID:`); - console.log(` Discord Settings → Advanced → ${bold("Developer Mode")} ON → Right-click yourself → Copy User ID\n`); - userId = (await rl.question(" Paste your User ID: ")).trim(); - console.log(` ${green("✓")} User: ${userId}\n`); + // Collect platform configs + const platforms: PlatformResult[] = []; + if (channelChoice === "telegram" || channelChoice === "both") { + platforms.push(await runTelegramFlow(rl)); + } + if (channelChoice === "discord" || channelChoice === "both") { + platforms.push(await runDiscordFlow(rl)); } + const primaryPlatform = platforms[0]; + // ── Project roots ──────────────────────────────────── console.log(bold("Project Roots (optional)")); @@ -313,49 +568,83 @@ export async function runQuickstart(): Promise { mkdirSync(DATA_DIR, { recursive: true }); - // Quote IDs that may be snowflakes (Discord 64-bit) - const qGid = groupId.length >= 16 ? `"${groupId}"` : groupId; - const qUid = userId.length >= 16 ? `"${userId}"` : userId; - - const fleetYaml = [ - "channel:", - ` type: ${channel}`, - " mode: topic", - ` bot_token_env: ${tokenEnvName}`, - ` group_id: ${qGid}`, - " access:", - " mode: locked", - " allowed_users:", - ` - ${qUid}`, - "", - ...(projectRoots.length > 0 - ? ["project_roots:", ...projectRoots.map(p => ` - ${p}`), ""] - : []), - "defaults:", - ` backend: ${backend}`, - "", - ].join("\n"); - - writeFileSync(FLEET_CONFIG_PATH, fleetYaml); + // Build fleet config object + const fleetObj: Record = {}; + if (platforms.length === 1) { + // Single platform: use channel (singular) for simplicity + fleetObj.channel = buildChannelConfig(platforms[0]); + delete fleetObj.channel.id; // not needed for single + } else { + // Multi-platform: use channels array + fleetObj.channels = platforms.map(p => buildChannelConfig(p)); + } + if (projectRoots.length > 0) fleetObj.project_roots = projectRoots; + fleetObj.defaults = { backend }; + + writeFileSync(FLEET_CONFIG_PATH, yaml.dump(fleetObj, { quotingType: '"', forceQuotes: false })); console.log(`\n ${green("✓")} ${FLEET_CONFIG_PATH}`); - writeFileSync(ENV_PATH, `${tokenEnvName}=${token}\n`); + // Write .env with all tokens + const envLines = platforms.map(p => `${p.tokenEnvName}=${p.token}`).join("\n") + "\n"; + writeFileSync(ENV_PATH, envLines, { mode: 0o600 }); + try { chmodSync(ENV_PATH, 0o600); } catch { /* best-effort on Windows */ } console.log(` ${green("✓")} ${ENV_PATH}`); + // ── ClassicBot setup (Discord only) ────────────────── + + const discordPlatform = platforms.find(p => p.type === "discord"); + const classicPath = join(DATA_DIR, "classicBot.yaml"); + if (discordPlatform && existsSync(classicPath)) { + await maybeUpdateClassicBot(rl); + } else if (discordPlatform) { + const setupClassic = await rl.question(`\n Set up ClassicBot? (allows /start in any channel) [Y/n] `); + if (setupClassic.toLowerCase() !== "n") { + const allowedGuilds: string[] = [discordPlatform.groupId]; + console.log(` ${green("✓")} Primary guild added: ${discordPlatform.groupId}`); + while (true) { + const more = (await rl.question(` Add another guild ID? (Enter to skip): `)).trim(); + if (!more) break; + allowedGuilds.push(more); + console.log(` ${green("✓")} Added: ${more}`); + } + + const cbBackend = (await rl.question(` Default backend [${backend}]: `)).trim() || backend; + + const adminUsers: string[] = [discordPlatform.userId]; + console.log(` ${green("✓")} Admin user added: ${discordPlatform.userId}`); + while (true) { + const uid = (await rl.question(` Add another admin user ID? (Enter to skip): `)).trim(); + if (!uid) break; + adminUsers.push(uid); + console.log(` ${green("✓")} Added: ${uid}`); + } + + writeFileSync(classicPath, `# ClassicBot Configuration\n${yaml.dump({ + defaults: { backend: cbBackend, allowed_guilds: allowedGuilds, admin_users: adminUsers }, + }, { quotingType: '"', forceQuotes: false })}`); + console.log(` ${green("✓")} ${classicPath}`); + } + } + // ── Next steps ─────────────────────────────────────── console.log(`\n${bold("═══ Setup Complete ═══")}\n`); - if (channel === "discord") { + const hasDiscord = platforms.some(p => p.type === "discord"); + if (hasDiscord) { console.log(" Next steps:"); - console.log(` 1. ${bold("npm install -g @suzuke/agend-plugin-discord")}`); + console.log(` 1. ${bold("npm install -g @songsid/agend-plugin-discord")}`); console.log(` 2. ${dim("(Optional)")} Edit ~/.agend/fleet.yaml to customize`); console.log(` 3. ${bold("agend fleet start")}`); - console.log(` 4. Talk to ${botUsername} in your Discord server\n`); + for (const p of platforms) { + if (p.type === "discord") console.log(` • Talk to ${p.botUsername} in your Discord server`); + else console.log(` • Talk to @${p.botUsername} in your Telegram group`); + } + console.log(`\n ${dim("Classic Bot Mode: Use /start in any Discord channel to start an agent. Use /chat to talk.")}\n`); } else { console.log(" Next steps:"); console.log(` 1. ${dim("(Optional)")} Edit ~/.agend/fleet.yaml to customize`); console.log(` 2. ${bold("agend fleet start")}`); - console.log(` 3. Talk to @${botUsername} in your Telegram group\n`); + console.log(` 3. Talk to @${primaryPlatform.botUsername} in your Telegram group\n`); } } finally { rl.close(); diff --git a/src/scheduler/scheduler.ts b/src/scheduler/scheduler.ts index eaf54d0f..2b56da7e 100644 --- a/src/scheduler/scheduler.ts +++ b/src/scheduler/scheduler.ts @@ -1,21 +1,15 @@ import { Cron } from "croner"; import { SchedulerDb } from "./db.js"; import type { Schedule, CreateScheduleParams, UpdateScheduleParams, SchedulerConfig, ScheduleRun } from "./types.js"; - -/** - * Reject unknown timezones. Uses `Intl.DateTimeFormat`, which throws RangeError - * for invalid IANA names but accepts canonical aliases like "UTC" that - * `Intl.supportedValuesOf("timeZone")` doesn't enumerate. - */ -function validateTimezone(tz: string): void { - try { - new Intl.DateTimeFormat("en-US", { timeZone: tz }); - } catch { - throw new Error(`Unknown timezone: ${tz}`); - } -} +import type { Logger } from "../logger.js"; +import { validateTimezone } from "../config.js"; export class Scheduler { + /** Cap how far back we look for missed fires on init. Avoids dumping + * dozens of "morning standup" pings on the user after a long outage, + * while still recovering from short crashes/restarts. */ + private static readonly CATCHUP_WINDOW_MS = 24 * 60 * 60 * 1000; + readonly db: SchedulerDb; private jobs: Map = new Map(); private onTrigger: (schedule: Schedule) => void | Promise; @@ -39,9 +33,44 @@ export class Scheduler { init(): void { this.db.pruneOldRuns(); + this.runCatchUp(); this.registerAllJobs(); } + /** + * On startup, fire any schedule whose most recent expected run was missed + * within the catch-up window. Only one catch-up fire per schedule — we + * don't replay every missed minute of `* * * * *`. Schedules that haven't + * been triggered yet use `created_at` as the reference point so a new + * schedule registered while the daemon was down still gets caught up. + */ + private runCatchUp(): void { + const now = Date.now(); + const cutoff = now - Scheduler.CATCHUP_WINDOW_MS; + for (const schedule of this.db.list()) { + if (!schedule.enabled) continue; + + const refIso = schedule.last_triggered_at ?? schedule.created_at; + // SQLite datetime('now') stores UTC without 'Z' suffix — append it for correct parsing + const refMs = Date.parse(refIso.endsWith("Z") ? refIso : refIso + "Z"); + if (Number.isNaN(refMs)) continue; + + try { + const cron = new Cron(schedule.cron, { timezone: schedule.timezone }); + const next = cron.nextRun(new Date(refMs)); + if (!next) continue; + const nextMs = next.getTime(); + if (nextMs > now) continue; // not yet due + if (nextMs < cutoff) continue; // too old, don't spam + if (this.executing.has(schedule.id)) continue; + this.runWithLock(schedule); + } catch { + // Bad cron expression or croner edge case — skip rather than crash init + continue; + } + } + } + reload(): void { this.stopAllJobs(); this.registerAllJobs(); @@ -54,7 +83,7 @@ export class Scheduler { create(params: CreateScheduleParams): Schedule { const tz = params.timezone ?? this.config.default_timezone; - validateTimezone(tz); + validateTimezone(tz, "timezone"); try { new Cron(params.cron, { timezone: tz }); } catch (err) { @@ -80,7 +109,7 @@ export class Scheduler { update(id: string, params: UpdateScheduleParams): Schedule { if (params.timezone !== undefined) { - validateTimezone(params.timezone); + validateTimezone(params.timezone, "timezone"); } if (params.cron !== undefined) { try { diff --git a/src/sd-notify.ts b/src/sd-notify.ts new file mode 100644 index 00000000..33983b7c --- /dev/null +++ b/src/sd-notify.ts @@ -0,0 +1,8 @@ +import { execFileSync } from "node:child_process"; + +export function sdNotify(state: string): void { + if (!process.env.NOTIFY_SOCKET) return; + try { + execFileSync("systemd-notify", [state], { stdio: "ignore", timeout: 5000 }); + } catch { /* best effort */ } +} diff --git a/src/service-installer.ts b/src/service-installer.ts index c7051cd8..77c9ba60 100644 --- a/src/service-installer.ts +++ b/src/service-installer.ts @@ -21,8 +21,48 @@ export function detectPlatform(): "macos" | "linux" { return platform() === "darwin" ? "macos" : "linux"; } +// A value ending up inside a systemd unit line (or plist ) must not +// contain control characters — a newline would let an attacker close the +// current directive and inject new ones (e.g. ExecStartPost=rm -rf ~). +// The `]]>` guard prevents escaping out of plist CDATA in future templates. +function assertSafeServiceValue(name: string, value: string): void { + if (/[\x00-\x1f\x7f]/.test(value)) { + throw new Error( + `Unsafe service template variable ${name}: contains control characters`, + ); + } + if (value.includes("]]>")) { + throw new Error( + `Unsafe service template variable ${name}: contains plist CDATA terminator`, + ); + } +} + +function assertAbsolutePath(name: string, value: string): void { + if (!value.startsWith("/")) { + throw new Error(`Service template variable ${name} must be an absolute path`); + } +} + +function validateVars(vars: ServiceVars & { path: string }): void { + assertSafeServiceValue("label", vars.label); + assertSafeServiceValue("execPath", vars.execPath); + assertSafeServiceValue("workingDirectory", vars.workingDirectory); + assertSafeServiceValue("logPath", vars.logPath); + assertSafeServiceValue("path", vars.path); + assertAbsolutePath("execPath", vars.execPath); + assertAbsolutePath("workingDirectory", vars.workingDirectory); + assertAbsolutePath("logPath", vars.logPath); + // label is used as a filename component — restrict to safe charset + if (!/^[A-Za-z0-9._-]+$/.test(vars.label)) { + throw new Error(`Service label must match [A-Za-z0-9._-]+, got: ${vars.label}`); + } +} + function withDefaults(vars: ServiceVars): ServiceVars & { path: string } { - return { ...vars, path: vars.path ?? process.env.PATH ?? "" }; + const full = { ...vars, path: vars.path ?? process.env.PATH ?? "" }; + validateVars(full); + return full; } export function renderLaunchdPlist(vars: ServiceVars): string { @@ -115,6 +155,7 @@ export function startService(): boolean { execSync(`launchctl bootstrap ${domain} ${plistPath}`, { stdio: "inherit" }); execSync(`launchctl enable ${domain}/${SERVICE_LABEL}`, { stdio: "inherit" }); } else { + try { execSync("systemctl --user daemon-reload", { stdio: "pipe" }); } catch {} execSync(`systemctl --user start ${SERVICE_LABEL}`, { stdio: "inherit" }); } return true; diff --git a/src/setup-wizard.ts b/src/setup-wizard.ts index 16c3d250..e76b73e1 100644 --- a/src/setup-wizard.ts +++ b/src/setup-wizard.ts @@ -1,5 +1,5 @@ import { createInterface } from "node:readline/promises"; -import { writeFileSync, mkdirSync, existsSync, readFileSync } from "node:fs"; +import { writeFileSync, mkdirSync, existsSync, readFileSync, chmodSync } from "node:fs"; import { join, resolve } from "node:path"; import { homedir } from "node:os"; import { stdin, stdout } from "node:process"; @@ -31,6 +31,27 @@ export async function verifyBotToken( } } +async function verifyDiscordToken(token: string): Promise<{ valid: boolean; username: string | null }> { + try { + const res = await fetch("https://discord.com/api/v10/users/@me", { + headers: { Authorization: `Bot ${token}` }, + }); + if (!res.ok) return { valid: false, username: null }; + const data = (await res.json()) as { username?: string }; + return { valid: true, username: data.username ?? null }; + } catch { return { valid: false, username: null }; } +} + +async function listDiscordGuilds(token: string): Promise<{ id: string; name: string }[]> { + try { + const res = await fetch("https://discord.com/api/v10/users/@me/guilds", { + headers: { Authorization: `Bot ${token}` }, + }); + if (!res.ok) return []; + return (await res.json()) as { id: string; name: string }[]; + } catch { return []; } +} + function bold(s: string): string { return `\x1b[1m${s}\x1b[0m`; } @@ -123,9 +144,11 @@ function expandHome(p: string): string { // ── Config builder (pure, testable) ───────────────────── export interface WizardAnswers { + channelType: "telegram" | "discord"; backend: string; botTokenEnv: string; groupId?: number; + guildId?: string; channelMode: string; accessMode: "locked" | "pairing"; allowedUsers: (number | string)[]; @@ -142,16 +165,29 @@ export function buildFleetConfig(answers: WizardAnswers): Record 0 ? { allowed_users: answers.allowedUsers } : {}), - }, - }; + if (answers.channelType === "discord") { + fleetData.channel = { + type: "discord", + mode: answers.channelMode, + bot_token_env: answers.botTokenEnv, + ...(answers.guildId ? { group_id: answers.guildId } : {}), + access: { + mode: answers.accessMode, + ...(answers.allowedUsers.length > 0 ? { allowed_users: answers.allowedUsers } : {}), + }, + }; + } else { + fleetData.channel = { + type: "telegram", + mode: answers.channelMode, + bot_token_env: answers.botTokenEnv, + ...(answers.groupId != null ? { group_id: answers.groupId } : {}), + access: { + mode: answers.accessMode, + ...(answers.allowedUsers.length > 0 ? { allowed_users: answers.allowedUsers } : {}), + }, + }; + } fleetData.defaults = { ...(answers.backend !== "claude-code" ? { backend: answers.backend } : {}), @@ -298,71 +334,74 @@ export async function runSetupWizard(): Promise { } } - // ── Step 2: Bot token ── - step(2, TOTAL_STEPS, "Telegram Bot Token"); - console.log(` ${dim("Get one from @BotFather on Telegram")}`); + // ── Step 2: Channel type ── + step(2, TOTAL_STEPS, "Channel Type"); + const channelTypeIdx = await choose(rl, "Which chat platform?", [ + { label: "Telegram", hint: "Forum Topics" }, + { label: "Discord", hint: "Server channels" }, + ], 0); + const channelType: "telegram" | "discord" = channelTypeIdx === 0 ? "telegram" : "discord"; - // Check if env var already set - let tokenEnvName = "AGEND_BOT_TOKEN"; + let tokenEnvName = ""; let token = ""; let botUsername = ""; + let groupId: number | undefined; + let guildId: string | undefined; + const allowedUsers: (number | string)[] = []; + const accessMode = "locked"; - // Check existing .env for token - if (existsSync(ENV_PATH)) { - const envContent = readFileSync(ENV_PATH, "utf-8"); - const match = envContent.match(/^([A-Z_]+)=(\d+:[A-Za-z0-9_-]{30,})/m); - if (match) { - const maskedToken = match[2].slice(0, 10) + "..."; - console.log(` ${dim(`Found existing token in .env: ${match[1]}=${maskedToken}`)}`); - const reuse = await confirm(rl, "Use existing token?"); - if (reuse) { - tokenEnvName = match[1]; - token = match[2]; + if (channelType === "telegram") { + // ── Step 3: Telegram Bot Token ── + step(3, TOTAL_STEPS, "Telegram Bot Token"); + console.log(` ${dim("Get one from @BotFather on Telegram")}`); + + tokenEnvName = "AGEND_BOT_TOKEN"; + + // Check existing .env for token + if (existsSync(ENV_PATH)) { + const envContent = readFileSync(ENV_PATH, "utf-8"); + const match = envContent.match(/^([A-Z_]+)=(\d+:[A-Za-z0-9_-]{30,})/m); + if (match) { + const maskedToken = match[2].slice(0, 10) + "..."; + console.log(` ${dim(`Found existing token in .env: ${match[1]}=${maskedToken}`)}`); + const reuse = await confirm(rl, "Use existing token?"); + if (reuse) { tokenEnvName = match[1]; token = match[2]; } } } - } - if (!token) { - token = await ask(rl, "Bot Token", { - validate: (v) => validateBotToken(v) ? null : "Invalid format. Expected: 123456789:ABC...", - }); - } - - console.log(` Verifying with Telegram API...`); - const verification = await verifyBotToken(token); - if (!verification.valid) { - console.log(` ${red("✗")} Token rejected by Telegram. Check your token.`); - rl.close(); - process.exit(1); - } - botUsername = verification.username!; - console.log(` ${green("✓")} @${botUsername}`); + if (!token) { + token = await ask(rl, "Bot Token", { + validate: (v) => validateBotToken(v) ? null : "Invalid format. Expected: 123456789:ABC...", + }); + } - tokenEnvName = await ask(rl, "Env variable name for token", { - default: tokenEnvName, - validate: (v) => /^[A-Z_][A-Z0-9_]*$/.test(v) ? null : "Must be uppercase with underscores (e.g., AGEND_BOT_TOKEN)", - }); + console.log(` Verifying with Telegram API...`); + const verification = await verifyBotToken(token); + if (!verification.valid) { + console.log(` ${red("✗")} Token rejected by Telegram. Check your token.`); + rl.close(); + process.exit(1); + } + botUsername = verification.username!; + console.log(` ${green("✓")} @${botUsername}`); - console.log(); - console.log(` ${yellow("⚠")} Only one service can poll a bot token at a time.`); - console.log(` ${dim("If this bot is also used by Claude Code's --channels telegram")}`); - console.log(` ${dim("plugin or any other polling service, stop it first.")}`); - console.log(` ${dim("Otherwise AgEnD will not receive messages.")}`); + tokenEnvName = await ask(rl, "Env variable name for token", { + default: tokenEnvName, + validate: (v) => /^[A-Z_][A-Z0-9_]*$/.test(v) ? null : "Must be uppercase with underscores", + }); - // ── Step 3: Mode ── - step(3, TOTAL_STEPS, "Channel Mode"); - const mode = "topic"; + console.log(); + console.log(` ${yellow("⚠")} Only one service can poll a bot token at a time.`); + console.log(` ${dim("If this bot is also used by another polling service, stop it first.")}`); - let groupId: number | undefined; - { + // ── Step 3b: Telegram Group ID ── + step(3, TOTAL_STEPS, "Telegram Group"); console.log(); console.log(` ${dim("To get the group ID:")}`); console.log(` ${dim("1. Add the bot to a Telegram group with Forum Topics enabled")}`); console.log(` ${dim("2. Send a message in the group")}`); console.log(` ${dim("3. Open https://api.telegram.org/bot/getUpdates")}`); - console.log(` ${dim(" Find \"chat\":{\"id\":-100...} in the response")}`); console.log(` ${dim(" Or: add @getidsbot to the group")}`); - console.log(` ${dim(" Group IDs are negative numbers, e.g., -1001234567890")}`); console.log(); const gidStr = await ask(rl, "Group ID", { @@ -374,38 +413,110 @@ export async function runSetupWizard(): Promise { }, }); groupId = parseInt(gidStr, 10); - } - - // ── Step 4: Access control ── - step(4, TOTAL_STEPS, "Access Control"); - const accessMode = "locked"; - - console.log(` ${dim("Only whitelisted Telegram user IDs can interact with the bot.")}`); - console.log(` ${dim("You can add more users later with: agend access add ")}`); - console.log(); - console.log(` ${dim("Your Telegram user ID — send /start to @userinfobot or @getidsbot")}`); - const allowedUsers: (number | string)[] = []; - const uidStr = await ask(rl, "Your Telegram user ID", { - validate: (v) => { - const n = parseInt(v, 10); - return isNaN(n) || n <= 0 ? "Must be a positive number" : null; - }, - }); - allowedUsers.push(uidStr); + // ── Step 4: Telegram Access Control ── + step(4, TOTAL_STEPS, "Access Control"); + console.log(` ${dim("Only whitelisted Telegram user IDs can interact with the bot.")}`); + console.log(` ${dim("Your Telegram user ID — send /start to @userinfobot or @getidsbot")}`); - let addMore = await confirm(rl, "Add another user?", false); - while (addMore) { - const uid = await ask(rl, "User ID", { + const uidStr = await ask(rl, "Your Telegram user ID", { validate: (v) => { const n = parseInt(v, 10); return isNaN(n) || n <= 0 ? "Must be a positive number" : null; }, }); - allowedUsers.push(uid); - addMore = await confirm(rl, "Add another user?", false); + allowedUsers.push(uidStr); + + let addMore = await confirm(rl, "Add another user?", false); + while (addMore) { + const uid = await ask(rl, "User ID", { + validate: (v) => { + const n = parseInt(v, 10); + return isNaN(n) || n <= 0 ? "Must be a positive number" : null; + }, + }); + allowedUsers.push(uid); + addMore = await confirm(rl, "Add another user?", false); + } + } else { + // ── Step 3: Discord Bot Token ── + step(3, TOTAL_STEPS, "Discord Bot Token"); + console.log(` ${dim("Create a bot at https://discord.com/developers/applications")}`); + + tokenEnvName = "AGEND_DISCORD_TOKEN"; + + // Check existing .env for Discord token + if (existsSync(ENV_PATH)) { + const envContent = readFileSync(ENV_PATH, "utf-8"); + const match = envContent.match(/^(AGEND_DISCORD_TOKEN)=(\S+)/m); + if (match) { + const masked = match[2].slice(0, 10) + "..."; + console.log(` ${dim(`Found existing token in .env: ${match[1]}=${masked}`)}`); + const reuse = await confirm(rl, "Use existing token?"); + if (reuse) { tokenEnvName = match[1]; token = match[2]; } + } + } + + if (!token) { + token = await ask(rl, "Bot Token", { + validate: (v) => v.length > 20 ? null : "Token too short", + }); + } + + console.log(` Verifying with Discord API...`); + const verification = await verifyDiscordToken(token); + if (!verification.valid) { + console.log(` ${red("✗")} Token rejected by Discord. Check your token.`); + rl.close(); + process.exit(1); + } + botUsername = verification.username!; + console.log(` ${green("✓")} ${botUsername}`); + + tokenEnvName = await ask(rl, "Env variable name for token", { + default: tokenEnvName, + validate: (v) => /^[A-Z_][A-Z0-9_]*$/.test(v) ? null : "Must be uppercase with underscores", + }); + + // ── Step 3b: Discord Guild Selection ── + step(3, TOTAL_STEPS, "Discord Server"); + console.log(` Fetching servers...`); + const guilds = await listDiscordGuilds(token); + if (guilds.length === 0) { + console.log(` ${red("✗")} Bot is not in any server. Invite it first.`); + rl.close(); + process.exit(1); + } + if (guilds.length === 1) { + guildId = guilds[0].id; + console.log(` ${green("✓")} ${guilds[0].name} (${guildId})`); + } else { + const guildIdx = await choose(rl, "Which server?", guilds.map(g => ({ label: g.name, hint: g.id })), 0); + guildId = guilds[guildIdx].id; + } + + // ── Step 4: Discord Access Control ── + step(4, TOTAL_STEPS, "Access Control"); + console.log(` ${dim("Only whitelisted Discord user IDs can interact with the bot.")}`); + console.log(` ${dim("Enable Developer Mode in Discord settings to copy user IDs.")}`); + + const uidStr = await ask(rl, "Your Discord user ID", { + validate: (v) => /^\d{17,20}$/.test(v) ? null : "Must be a Discord snowflake ID (17-20 digits)", + }); + allowedUsers.push(uidStr); + + let addMore = await confirm(rl, "Add another user?", false); + while (addMore) { + const uid = await ask(rl, "User ID", { + validate: (v) => /^\d{17,20}$/.test(v) ? null : "Must be a Discord snowflake ID", + }); + allowedUsers.push(uid); + addMore = await confirm(rl, "Add another user?", false); + } } + const mode = "topic"; + // ── Step 5: Project roots ── step(5, TOTAL_STEPS, "Project Roots"); console.log(` ${dim("Directories containing your projects (for auto-bind browsing)")}`); @@ -553,10 +664,11 @@ export async function runSetupWizard(): Promise { // ── Step 9: Summary ── step(9, TOTAL_STEPS, "Summary"); console.log(); + console.log(` ${bold("Channel:")} ${channelType}`); console.log(` ${bold("Backend:")} ${selectedBackend.label}`); - console.log(` ${bold("Bot:")} @${botUsername}`); + console.log(` ${bold("Bot:")} ${channelType === "telegram" ? "@" : ""}${botUsername}`); console.log(` ${bold("Token env:")} ${tokenEnvName}`); - console.log(` ${bold("Mode:")} ${mode}${groupId ? ` (group: ${groupId})` : ""}`); + console.log(` ${bold("Mode:")} ${mode}${groupId ? ` (group: ${groupId})` : ""}${guildId ? ` (guild: ${guildId})` : ""}`); console.log(` ${bold("Access:")} ${accessMode}${allowedUsers.length > 0 ? ` — users: ${allowedUsers.join(", ")}` : ""}`); console.log(` ${bold("Roots:")} ${projectRoots.join(", ") || dim("(none)")}`); if (instances.length > 0) { @@ -599,15 +711,22 @@ export async function runSetupWizard(): Promise { } envContent += `${tokenEnvName}=${token}\n`; if (groqApiKey) envContent += `GROQ_API_KEY=${groqApiKey}\n`; - writeFileSync(ENV_PATH, envContent); + // .env contains the bot token (and possibly third-party API keys) — restrict + // to owner read/write so other local users / curious processes can't grab it. + writeFileSync(ENV_PATH, envContent, { mode: 0o600 }); + // writeFileSync's mode is only honoured when the file did not previously + // exist; chmod the realised file to cover the overwrite case as well. + try { chmodSync(ENV_PATH, 0o600); } catch { /* best-effort on Windows */ } console.log(` ${green("✓")} ${ENV_PATH}`); // fleet.yaml const yaml = await import("js-yaml"); const fleetData = buildFleetConfig({ + channelType, backend: selectedBackend.id, botTokenEnv: tokenEnvName, groupId, + guildId, channelMode: mode, accessMode, allowedUsers, @@ -643,12 +762,19 @@ export async function runSetupWizard(): Promise { // ── Done ── console.log(`\n${green("✓")} ${bold("Setup complete!")}`); - console.log(` Bot: @${botUsername}`); + console.log(` Bot: ${channelType === "telegram" ? "@" : ""}${botUsername}`); console.log(` Config: ${FLEET_CONFIG_PATH}`); + if (channelType === "discord") { + console.log(` ${dim("Install Discord plugin: npm install -g @songsid/agend-plugin-discord")}`); + } console.log(); console.log(` Start the fleet:`); console.log(` ${dim("agend fleet start")}`); - if (mode === "topic") { + if (channelType === "discord") { + console.log(); + console.log(` ${dim("Classic Bot Mode: Use /start in any Discord channel to start an agent. Use /chat to talk.")}`); + } + if (mode === "topic" && channelType === "telegram") { console.log(); console.log(` ${dim("Create a new topic in the group — the bot will auto-detect it")}`); console.log(` ${dim("and let you bind it to a project.")}`); diff --git a/src/tmux-control.ts b/src/tmux-control.ts index 470b8c4d..f07d3c02 100644 --- a/src/tmux-control.ts +++ b/src/tmux-control.ts @@ -33,6 +33,7 @@ export class TmuxControlClient extends EventEmitter { private rl: Interface | null = null; private lastOutputAt = new Map(); // paneId → timestamp private paneToWindow = new Map(); // paneId → windowId + private registeredWindows = new Set(); // windowIds we should re-resolve on reconnect private stopped = false; private reconnectTimer: ReturnType | null = null; @@ -65,6 +66,24 @@ export class TmuxControlClient extends EventEmitter { * Call this after createWindow(). */ async registerWindow(windowId: string): Promise { + this.registeredWindows.add(windowId); + await this.resolvePane(windowId); + } + + /** Unregister a window (call on killWindow) */ + unregisterWindow(windowId: string): void { + this.registeredWindows.delete(windowId); + for (const [pane, win] of this.paneToWindow) { + if (win === windowId) { + this.paneToWindow.delete(pane); + this.lastOutputAt.delete(pane); + break; + } + } + } + + /** Resolve a window's current pane id and cache the mapping. */ + private async resolvePane(windowId: string): Promise { try { const paneId = await execTmux([ "list-panes", "-t", `${this.sessionName}:${windowId}`, @@ -79,17 +98,6 @@ export class TmuxControlClient extends EventEmitter { } } - /** Unregister a window (call on killWindow) */ - unregisterWindow(windowId: string): void { - for (const [pane, win] of this.paneToWindow) { - if (win === windowId) { - this.paneToWindow.delete(pane); - this.lastOutputAt.delete(pane); - break; - } - } - } - /** Check if a window's pane has been silent for at least silenceMs */ isIdle(windowId: string): boolean { const paneId = this.windowToPaneId(windowId); @@ -161,6 +169,13 @@ export class TmuxControlClient extends EventEmitter { private connect(): void { if (this.stopped) return; + // Pane IDs are tmux-server-scoped: a server restart (or a long-enough + // disconnect that windows churned) can leave our cached paneId → + // windowId mapping pointing at a stale or recycled pane. Drop the + // cache and re-resolve every registered window from the new server. + this.paneToWindow.clear(); + this.lastOutputAt.clear(); + this.proc = spawn("tmux", tmuxArgs(["-C", "attach", "-t", this.sessionName, "-r"]), { stdio: ["pipe", "pipe", "pipe"], }); @@ -180,6 +195,12 @@ export class TmuxControlClient extends EventEmitter { this.logger?.warn({ err: (err as Error).message }, "Control mode spawn error"); }); + // Re-resolve panes for any windows that were registered before this + // (re)connect. Safe even on first connect: registeredWindows is empty. + for (const windowId of this.registeredWindows) { + void this.resolvePane(windowId); + } + this.logger?.debug("tmux control mode connected"); } diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index f1fa2ad5..4ce9bca1 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -150,7 +150,7 @@ export class TmuxManager { } catch { return false; } } - async sendSpecialKey(key: "Enter" | "Escape" | "Up" | "Down"): Promise { + async sendSpecialKey(key: "Enter" | "Escape" | "Up" | "Down" | "Right" | "Left"): Promise { try { await exec("tmux", TmuxManager.tmuxArgs(["send-keys", "-t", `${this.sessionName}:${this.windowId}`, key])); return true; @@ -163,13 +163,23 @@ export class TmuxManager { const bufName = `paste-${this.windowId}-${Date.now()}`; await exec("tmux", TmuxManager.tmuxArgs(["set-buffer", "-b", bufName, "--", text])); await exec("tmux", TmuxManager.tmuxArgs(["paste-buffer", "-d", "-b", bufName, "-t", target, "-p"])); - await new Promise(r => setTimeout(r, 200)); + await new Promise(r => setTimeout(r, 500)); + await exec("tmux", TmuxManager.tmuxArgs(["send-keys", "-t", target, "Enter"])); + // Retry Enter: if TUI was busy outputting, the first Enter may be swallowed. + // A second Enter on an empty input is a no-op for all supported CLIs. + await new Promise(r => setTimeout(r, 1000)); await exec("tmux", TmuxManager.tmuxArgs(["send-keys", "-t", target, "Enter"])); return true; } catch { return false; } } async pipeOutput(logPath: string): Promise { + // pipe-pane's shell command runs via /bin/sh -c. Reject control characters + // that would break out of the single-quote escape (newlines, NULs, etc.); + // only expect absolute paths produced by getAgendHome() / instanceDir. + if (/[\x00-\x1f]/.test(logPath)) { + throw new Error(`Invalid log path (contains control characters): ${JSON.stringify(logPath)}`); + } const escaped = logPath.replace(/'/g, "'\\''"); await exec("tmux", TmuxManager.tmuxArgs([ "pipe-pane", "-t", `${this.sessionName}:${this.windowId}`, diff --git a/src/topic-archiver.ts b/src/topic-archiver.ts index 28bbf4fe..93f519c8 100644 --- a/src/topic-archiver.ts +++ b/src/topic-archiver.ts @@ -1,3 +1,5 @@ +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; import type { FleetConfig } from "./types.js"; import type { ChannelAdapter } from "./channel/types.js"; import type { Logger } from "./logger.js"; @@ -6,6 +8,7 @@ export interface ArchiverContext { readonly fleetConfig: FleetConfig | null; readonly adapter: ChannelAdapter | null; readonly logger: Logger; + readonly dataDir: string; getInstanceStatus(name: string): "running" | "stopped" | "crashed"; lastActivityMs(name: string): number; setTopicIcon(name: string, state: "green" | "blue" | "red" | "remove"): void; @@ -14,15 +17,43 @@ export interface ArchiverContext { /** * Manages automatic archival (close) and reopening of idle forum topics. + * + * Archived state is persisted to `/archived-topics.json` so a daemon + * restart does not re-archive (or re-message) topics that were already closed. */ export class TopicArchiver { private archived = new Set(); private timer: ReturnType | null = null; + private readonly statePath: string; static readonly IDLE_MS = 24 * 60 * 60 * 1000; // 24 hours private static readonly POLL_MS = 30 * 60_000; // check every 30 minutes - constructor(private ctx: ArchiverContext) {} + constructor(private ctx: ArchiverContext) { + this.statePath = join(ctx.dataDir, "archived-topics.json"); + this.load(); + } + + private load(): void { + if (!existsSync(this.statePath)) return; + try { + const arr: unknown = JSON.parse(readFileSync(this.statePath, "utf-8")); + if (!Array.isArray(arr)) return; + for (const id of arr) { + if (typeof id === "string") this.archived.add(id); + } + } catch (err) { + this.ctx.logger.warn({ err, path: this.statePath }, "Failed to load archived-topics state"); + } + } + + private save(): void { + try { + writeFileSync(this.statePath, JSON.stringify([...this.archived])); + } catch (err) { + this.ctx.logger.warn({ err, path: this.statePath }, "Failed to save archived-topics state"); + } + } /** Is this topic currently archived? */ isArchived(topicId: string): boolean { @@ -65,6 +96,7 @@ export class TopicArchiver { this.ctx.logger.info({ name, topicId, idleHours: Math.round((now - last) / 3600000) }, "Archiving idle topic"); this.archived.add(topicIdStr); + this.save(); this.ctx.setTopicIcon(name, "remove"); await this.ctx.adapter.closeForumTopic(topicId); } @@ -74,6 +106,7 @@ export class TopicArchiver { async reopen(topicId: string, instanceName: string): Promise { if (!this.archived.has(topicId)) return; this.archived.delete(topicId); + this.save(); if (this.ctx.adapter?.reopenForumTopic) { await this.ctx.adapter.reopenForumTopic(topicId); diff --git a/src/topic-commands.ts b/src/topic-commands.ts index d766a47f..219c3d0e 100644 --- a/src/topic-commands.ts +++ b/src/topic-commands.ts @@ -1,12 +1,13 @@ import { readFileSync, existsSync } from "node:fs"; -import { exec } from "node:child_process"; +import { exec, spawn } from "node:child_process"; import { promisify } from "node:util"; import { join, basename } from "node:path"; import { homedir } from "node:os"; +import { createRequire } from "node:module"; const execAsync = promisify(exec); import type { FleetContext } from "./fleet-context.js"; -import type { InboundMessage } from "./channel/types.js"; +import type { ChannelAdapter, InboundMessage } from "./channel/types.js"; import { DEFAULT_INSTANCE_CONFIG } from "./config.js"; import { formatCents } from "./cost-guard.js"; import { detectPlatform } from "./service-installer.js"; @@ -20,6 +21,14 @@ export function sanitizeInstanceName(name: string): string { export class TopicCommands { constructor(private ctx: FleetContext) {} + /** Get the adapter that should reply to a given inbound message */ + private getReplyAdapter(msg: InboundMessage): ChannelAdapter | null { + if (msg.adapterId && this.ctx.adapters) { + return this.ctx.adapters.get(msg.adapterId) ?? this.ctx.adapter; + } + return this.ctx.adapter; + } + /** Parse and dispatch commands from the General topic */ async handleGeneralCommand(msg: InboundMessage): Promise { const text = msg.text?.trim(); @@ -41,27 +50,74 @@ export class TopicCommands { return true; } - if (text === "/update" || text === "/update@" || text.startsWith("/update@")) { - await this.handleUpdateCommand(msg); + if (text === "/doctor" || text.startsWith("/doctor@")) { + await this.handleDoctorCommand(msg); return true; } return false; } + /** Handle /ctx in any instance topic — returns true if handled */ + async handleInstanceCommand(msg: InboundMessage, instanceName: string): Promise { + const text = msg.text?.trim(); + if (!text) return false; + if (text !== "/ctx" && !text.startsWith("/ctx@")) return false; + + const adapter = this.getReplyAdapter(msg); + if (!adapter) return false; + + const backend = this.ctx.fleetConfig?.instances[instanceName]?.backend + ?? this.ctx.fleetConfig?.defaults?.backend ?? "claude-code"; + let context: number | null = null; + try { + const statusFile = join(this.ctx.dataDir, "instances", instanceName, "statusline.json"); + if (existsSync(statusFile)) { + const d = JSON.parse(readFileSync(statusFile, "utf-8")); + context = d.context_window?.used_percentage ?? null; + } + } catch { /* ignore */ } + if (context == null) { + try { + const { execFileSync } = await import("node:child_process"); + const { getTmuxSocketName, getTmuxSessionName } = await import("./paths.js"); + const socketName = getTmuxSocketName(); + const tmuxArgs = socketName + ? ["-L", socketName, "capture-pane", "-t", `${getTmuxSessionName()}:${instanceName}`, "-p"] + : ["capture-pane", "-t", `${getTmuxSessionName()}:${instanceName}`, "-p"]; + const pane = execFileSync("tmux", tmuxArgs, { encoding: "utf-8", timeout: 2000, stdio: ["pipe", "pipe", "pipe"] }); + const m = pane.match(/(\d+)%.*[!❯>]/m) || pane.match(/◔\s*(\d+)%/) || pane.match(/\[(\d+)%\]/); + if (m) context = parseInt(m[1], 10); + } catch { /* ignore */ } + } + const reply = context != null + ? `📊 Context: ${context}% used\nBackend: ${backend}\nInstance: ${instanceName}` + : `Context info not available yet.\nBackend: ${backend}\nInstance: ${instanceName}`; + await adapter.sendText(msg.chatId, reply, { threadId: msg.threadId }); + return true; + } + private async handleRestartCommand(msg: InboundMessage): Promise { - if (!this.ctx.adapter) return; + const adapter = this.getReplyAdapter(msg); + if (!adapter) return; const chatId = msg.chatId; const threadId = msg.threadId; - await this.ctx.adapter.sendText(chatId, "🔄 Graceful restart — waiting for instances to idle...", { threadId }); - // SIGUSR2 triggers in-process restart (safe without service manager) + + const allowed = this.ctx.fleetConfig?.channel?.access?.allowed_users ?? []; + if (allowed.length > 0 && !allowed.some(u => String(u) === String(msg.userId))) { + await adapter.sendText(chatId, "⛔ Not authorized", { threadId }); + return; + } + + await adapter.sendText(chatId, "🔄 Graceful restart — waiting for instances to idle...", { threadId }); process.kill(process.pid, "SIGUSR2"); } private async handleStatusCommand(msg: InboundMessage): Promise { - if (!this.ctx.adapter || !this.ctx.fleetConfig) return; + const adapter = this.getReplyAdapter(msg); + if (!adapter || !this.ctx.fleetConfig) return; - const lines: string[] = []; + const rows: string[] = []; for (const [name] of Object.entries(this.ctx.fleetConfig.instances)) { const status = this.ctx.getInstanceStatus(name); const paused = this.ctx.costGuard?.isLimited(name); @@ -73,8 +129,22 @@ export class TopicCommands { contextStr = `${Math.round(data.context_window.used_percentage)}%`; } } catch { /* file may not exist yet */ } + if (contextStr === "-") { + try { + const { execFileSync } = await import("node:child_process"); + const { getTmuxSocketName, getTmuxSessionName } = await import("./paths.js"); + const socketName = getTmuxSocketName(); + const tmuxArgs = socketName + ? ["-L", socketName, "capture-pane", "-t", `${getTmuxSessionName()}:${name}`, "-p"] + : ["capture-pane", "-t", `${getTmuxSessionName()}:${name}`, "-p"]; + const pane = execFileSync("tmux", tmuxArgs, { encoding: "utf-8", timeout: 2000, stdio: ["pipe", "pipe", "pipe"] }); + const m = pane.match(/(\d+)%.*[!❯>]/m) || pane.match(/◔\s*(\d+)%/) || pane.match(/\[(\d+)%\]/); + if (m) contextStr = `${parseInt(m[1], 10)}%`; + } catch { /* tmux capture failed */ } + } const costCents = this.ctx.costGuard?.getDailyCostCents(name) ?? 0; + const backend = this.ctx.fleetConfig.instances[name]?.backend ?? this.ctx.fleetConfig.defaults?.backend ?? "-"; let icon: string; if (paused) icon = "⏸"; @@ -82,120 +152,153 @@ export class TopicCommands { else if (status === "crashed") icon = "🔴"; else icon = "⚪"; - lines.push(`${icon} ${name} — ctx ${contextStr}, ${formatCents(costCents)} today`); + rows.push(`| ${name} | ${backend} | ${contextStr} | ${formatCents(costCents)} | ${icon} |`); } - if (lines.length === 0) { - lines.push("No instances configured."); + if (rows.length === 0) { + await adapter.sendText(msg.chatId, "No instances configured.", { threadId: msg.threadId }); + return; } + const lines = [ + "## Fleet Status", + "", + "| Instance | Backend | Ctx | Cost | Status |", + "|----------|---------|-----|------|--------|", + ...rows, + ]; + const limitCents = this.ctx.costGuard?.getLimitCents() ?? 0; const totalCents = this.ctx.costGuard?.getFleetTotalCents() ?? 0; if (limitCents > 0) { - lines.push(""); - lines.push(`Fleet: ${formatCents(totalCents)} / ${formatCents(limitCents)} daily`); + lines.push("", `Fleet: ${formatCents(totalCents)} / ${formatCents(limitCents)} daily`); } - await this.ctx.adapter.sendText(msg.chatId, lines.join("\n")); + await adapter.sendText(msg.chatId, lines.join("\n"), { threadId: msg.threadId }); } private async handleSysInfoCommand(msg: InboundMessage): Promise { - if (!this.ctx.adapter) return; + const adapter = this.getReplyAdapter(msg); + if (!adapter) return; const info = this.ctx.getSysInfo(); const upHours = Math.floor(info.uptime_seconds / 3600); const upMins = Math.floor((info.uptime_seconds % 3600) / 60); + const require = createRequire(import.meta.url); + const agendVersion = require("../package.json").version ?? "unknown"; + const lines: string[] = [ - `⚙️ System Info`, - `Uptime: ${upHours}h ${upMins}m`, - `Memory: ${info.memory_mb.rss} MB RSS, ${info.memory_mb.heapUsed}/${info.memory_mb.heapTotal} MB heap`, + "## System Info", + "", + "| Metric | Value |", + "|--------|-------|", + `| AgEnD | v${agendVersion} |`, + `| Uptime | ${upHours}h ${upMins}m |`, + `| Memory | ${info.memory_mb.rss} MB RSS |`, + `| Heap | ${info.memory_mb.heapUsed} / ${info.memory_mb.heapTotal} MB |`, + "", + "## Instances", "", - "Instances:", + "| Name | IPC | Cost | Rate |", + "|------|-----|------|------|", ]; for (const inst of info.instances) { const icon = inst.status === "running" ? "🟢" : inst.status === "crashed" ? "🔴" : "⚪"; const ipc = inst.ipc ? "✓" : "✗"; - let detail = `${icon} ${inst.name} [IPC:${ipc}] ${formatCents(inst.costCents)}`; - if (inst.rateLimits) { - detail += ` (5h:${inst.rateLimits.five_hour_pct}% 7d:${inst.rateLimits.seven_day_pct}%)`; - } - lines.push(detail); + const rate = inst.rateLimits ? `5h:${inst.rateLimits.five_hour_pct}%` : "-"; + lines.push(`| ${icon} ${inst.name} | ${ipc} | ${formatCents(inst.costCents)} | ${rate} |`); } if (info.fleet_cost_limit_cents > 0) { - lines.push(""); - lines.push(`Fleet cost: ${formatCents(info.fleet_cost_cents)} / ${formatCents(info.fleet_cost_limit_cents)} daily`); + lines.push("", `Fleet cost: ${formatCents(info.fleet_cost_cents)} / ${formatCents(info.fleet_cost_limit_cents)} daily`); } - await this.ctx.adapter.sendText(msg.chatId, lines.join("\n"), { threadId: msg.threadId }); + await adapter.sendText(msg.chatId, lines.join("\n"), { threadId: msg.threadId }); } private async handleUpdateCommand(msg: InboundMessage): Promise { - if (!this.ctx.adapter) return; + const adapter = this.getReplyAdapter(msg); + if (!adapter) return; const chatId = msg.chatId; const threadId = msg.threadId; - // Access control — only allowed users can trigger update + // Access control — only allowed users can trigger update; empty = disabled const allowed = this.ctx.fleetConfig?.channel?.access?.allowed_users ?? []; - if (allowed.length > 0 && !allowed.some(u => String(u) === String(msg.userId))) { - await this.ctx.adapter.sendText(chatId, "⛔ Not authorized", { threadId }); + if (allowed.length === 0) { + await adapter.sendText(chatId, "⛔ /update disabled — no allowed_users configured", { threadId }); + return; + } + if (!allowed.some(u => String(u) === String(msg.userId))) { + await adapter.sendText(chatId, "⛔ Not authorized", { threadId }); return; } - await this.ctx.adapter.sendText(chatId, "📦 Updating AgEnD...", { threadId }); + await adapter.sendText(chatId, "📦 Updating AgEnD...", { threadId }); try { - await execAsync("npm install -g @suzuke/agend@latest", { timeout: 120_000 }); + await execAsync("npm install -g @songsid/agend@latest", { timeout: 120_000 }); } catch { - await this.ctx.adapter.sendText(chatId, "❌ npm install failed. Try manually: npm install -g @suzuke/agend@latest", { threadId }); + await adapter.sendText(chatId, "❌ npm install failed. Try manually: npm install -g @songsid/agend@latest", { threadId }); return; } - await this.ctx.adapter.sendText(chatId, "✅ Updated. Restarting service...", { threadId }); - // Brief delay to let sendText complete before process dies - await new Promise(r => setTimeout(r, 1000)); + // Get new version + let newVersion = "latest"; + try { + const { stdout } = await execAsync("npm view @songsid/agend version", { timeout: 10_000 }); + newVersion = stdout.trim(); + } catch { /* use "latest" */ } + + await adapter.sendText(chatId, `✅ Updated to v${newVersion}. Restarting in 2s...`, { threadId }); const label = "com.agend.fleet"; const plat = detectPlatform(); if (plat === "macos") { - const plistPath = join(homedir(), "Library/LaunchAgents", `${label}.plist`); - if (existsSync(plistPath)) { - const uid = process.getuid?.() ?? 501; - try { - await execAsync(`launchctl kickstart -k gui/${uid}/${label}`, { timeout: 15_000 }); - return; - } catch { - await this.ctx.adapter.sendText(chatId, "⚠️ Failed to restart launchd service", { threadId }); - return; - } - } + const uid = process.getuid?.() ?? 501; + const child = spawn("sh", ["-c", `sleep 2 && launchctl kickstart -k gui/${uid}/${label}`], { + detached: true, stdio: "ignore", + }); + child.unref(); } else { - try { - await execAsync(`systemctl --user restart ${label}`, { timeout: 15_000 }); - return; - } catch { /* no systemd service */ } + const child = spawn("sh", ["-c", `sleep 2 && systemctl --user daemon-reload && systemctl --user reset-failed ${label} 2>/dev/null; systemctl --user restart ${label}`], { + detached: true, stdio: "ignore", + }); + child.unref(); } + } - // Fallback: signal running daemon - const pidPath = join(this.ctx.dataDir, "fleet.pid"); - if (existsSync(pidPath)) { - const pid = parseInt(readFileSync(pidPath, "utf-8").trim(), 10); - try { - process.kill(pid, "SIGUSR1"); - } catch { - await this.ctx.adapter.sendText(chatId, "⚠️ Fleet not running", { threadId }); - } - } else { - await this.ctx.adapter.sendText(chatId, "⚠️ No service or running fleet found", { threadId }); + private async handleDoctorCommand(msg: InboundMessage): Promise { + const adapter = this.getReplyAdapter(msg); + if (!adapter) return; + const chatId = msg.chatId; + const threadId = msg.threadId; + + const allowed = this.ctx.fleetConfig?.channel?.access?.allowed_users ?? []; + if (allowed.length > 0 && !allowed.some(u => String(u) === String(msg.userId))) { + await adapter.sendText(chatId, "⛔ Not authorized", { threadId }); + return; + } + + await adapter.sendText(chatId, "🩺 Running diagnostics...", { threadId }); + try { + const { execSync } = await import("node:child_process"); + const backend = this.ctx.fleetConfig?.defaults?.backend || "claude-code"; + const result = execSync(`agend backend doctor ${backend}`, { timeout: 30_000, encoding: "utf-8" }); + const clean = result.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, ""); + await adapter.sendText(chatId, clean || "No output", { threadId }); + } catch (err: any) { + const output = (err.stdout ?? err.message ?? "Doctor failed").replace(/\x1b\[[0-9;]*[a-zA-Z]/g, ""); + await adapter.sendText(chatId, output, { threadId }); } } /** Reply with redirect when message arrives in an unbound topic */ async handleUnboundTopic(msg: InboundMessage): Promise { - if (!this.ctx.adapter) return; - await this.ctx.adapter.sendText( + const adapter = this.getReplyAdapter(msg); + if (!adapter) return; + await adapter.sendText( msg.chatId, "This topic is not bound to an instance. Ask the General assistant to create one with create_instance.", { threadId: msg.threadId }, @@ -232,11 +335,11 @@ export class TopicCommands { this.ctx.saveFleetConfig(); this.ctx.routingTable.set(String(topicId), { kind: "instance", name: instanceName }); + // startInstance awaits lifecycle.start → daemon.start (IPC listening) → + // connectIpcToInstance. By the time it resolves, IPC is already wired — + // the previous code's 5s sleep + second connect was leftover paranoia. await this.ctx.startInstance(instanceName, this.ctx.fleetConfig.instances[instanceName], true); - await new Promise(r => setTimeout(r, 5000)); - await this.ctx.connectIpcToInstance(instanceName); - this.ctx.logger.info({ instanceName, topicId }, "Topic bound and started"); return instanceName; } @@ -251,11 +354,26 @@ export class TopicCommands { for (const [name, config] of Object.entries(this.ctx.fleetConfig.instances)) { if (config.topic_id != null) continue; - // Telegram's native General topic always has thread_id = 1 + // General topic: determine platform type from channel_id → channels config if (config.general_topic) { - config.topic_id = 1; + const channels = this.ctx.fleetConfig?.channels ?? (this.ctx.fleetConfig?.channel ? [this.ctx.fleetConfig.channel] : []); + let platformType: string | undefined; + if ((config as any).channel_id) { + const matched = channels.find(c => (c.id ?? c.type) === (config as any).channel_id); + platformType = matched?.type; + } + if (!platformType) { + if (name.includes("telegram")) platformType = "telegram"; + else if (name.includes("discord")) platformType = "discord"; + } + if (platformType === "discord") { + const ch = channels.find(c => c.type === "discord"); + config.topic_id = (ch?.options?.general_channel_id as string | number) ?? 1; + } else { + config.topic_id = 1; + } configChanged = true; - this.ctx.logger.info({ name, topicId: 1 }, "Bound to native General topic"); + this.ctx.logger.info({ name, topicId: config.topic_id, platformType }, "Bound to General topic"); continue; } @@ -277,32 +395,55 @@ export class TopicCommands { /** Register bot commands in Telegram command menu */ async registerBotCommands(): Promise { - const groupId = this.ctx.fleetConfig?.channel?.group_id; - const botTokenEnv = this.ctx.fleetConfig?.channel?.bot_token_env; - if (!groupId || !botTokenEnv) return; - const botToken = process.env[botTokenEnv]; - if (!botToken) return; + // Register bot commands for all Telegram adapters (channels[] support) + const channels = this.ctx.fleetConfig?.channels ?? (this.ctx.fleetConfig?.channel ? [this.ctx.fleetConfig.channel] : []); + const telegramChannels = channels.filter(ch => ch.type === "telegram"); + if (telegramChannels.length === 0) return; - try { - await fetch( - `https://api.telegram.org/bot${botToken}/setMyCommands`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - commands: [ - { command: "status", description: "Show fleet status and costs" }, - { command: "restart", description: "Graceful restart all instances" }, - { command: "sysinfo", description: "System diagnostics" }, - { command: "update", description: "Update AgEnD and restart service" }, - ], - scope: { type: "chat", chat_id: groupId }, - }), - }, - ); - this.ctx.logger.info("Registered bot commands: /status"); - } catch (err) { - this.ctx.logger.warn({ err }, "Failed to register bot commands (non-fatal)"); + for (const ch of telegramChannels) { + const botToken = process.env[ch.bot_token_env]; + if (!botToken || !ch.group_id) continue; + + try { + // Register admin commands for the forum group + await fetch( + `https://api.telegram.org/bot${botToken}/setMyCommands`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + commands: [ + { command: "status", description: "[Fleet] Show fleet status and costs" }, + { command: "restart", description: "[Fleet] Graceful restart all instances" }, + { command: "sysinfo", description: "[Fleet] System diagnostics" }, + { command: "update", description: "[Fleet] Update AgEnD to latest" }, + { command: "doctor", description: "[Fleet] Run health diagnostics" }, + ], + scope: { type: "chat", chat_id: ch.group_id }, + }), + }, + ); + + // Register classic bot commands for private chats and all groups + await fetch( + `https://api.telegram.org/bot${botToken}/setMyCommands`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + commands: [ + { command: "start", description: "[ClassicBot] Start an agent in this chat" }, + { command: "stop", description: "[ClassicBot] Stop the agent" }, + ], + scope: { type: "default" }, + }), + }, + ); + + this.ctx.logger.info({ adapterId: ch.id ?? ch.type }, "Registered bot commands: /status (forum), /start /stop (default)"); + } catch (err) { + this.ctx.logger.warn({ err, adapterId: ch.id ?? ch.type }, "Failed to register bot commands (non-fatal)"); + } } } } diff --git a/src/transcript-monitor.ts b/src/transcript-monitor.ts index d21d9835..c18e3934 100644 --- a/src/transcript-monitor.ts +++ b/src/transcript-monitor.ts @@ -10,6 +10,7 @@ export class TranscriptMonitor extends EventEmitter { private transcriptPath: string | null = null; private pollTimer: ReturnType | null = null; private offsetFile: string; + private polling = false; // reentry guard for pollIncrement constructor(private instanceDir: string, private logger: Logger) { super(); @@ -54,6 +55,19 @@ export class TranscriptMonitor extends EventEmitter { } async pollIncrement(): Promise { + // Reentry guard: setInterval keeps firing even when the previous poll is + // still awaiting stat/read. Two concurrent runs would race on byteOffset + // (both could read the same byte range and emit duplicate entries). + if (this.polling) return; + this.polling = true; + try { + await this._doPoll(); + } finally { + this.polling = false; + } + } + + private async _doPoll(): Promise { if (!this.transcriptPath) { this.transcriptPath = await this.resolveTranscriptPath(); if (!this.transcriptPath) return; diff --git a/src/types.ts b/src/types.ts index 20d772b3..c74de97f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -26,7 +26,7 @@ export interface StatusLineData { } export interface AccessConfig { - mode: "pairing" | "locked"; + mode: "pairing" | "locked" | "open"; allowed_users: (number | string)[]; max_pending_codes: number; code_expiry_minutes: number; @@ -50,6 +50,7 @@ export interface DailySummaryConfig { } export interface ChannelConfig { + id?: string; type: string; mode: "topic"; bot_token_env: string; @@ -71,6 +72,8 @@ export interface InstanceConfig { /** Tags for capability discovery (e.g. ["code-reviewer", "researcher", "executor"]) */ tags?: string[]; topic_id?: number | string; + /** Which channel adapter this instance is bound to (matches channel `id` field). Used for multi-channel general routing. */ + channel_id?: string; general_topic?: boolean; restart_policy: { max_retries: number; @@ -108,6 +111,8 @@ export interface InstanceConfig { startup_timeout_ms?: number; /** Agent communication mode: "mcp" (default) or "cli" (HTTP endpoint, no MCP server). */ agent_mode?: "mcp" | "cli"; + /** Command to paste raw before each user message (e.g. "/chat load base.json") */ + pre_task_command?: string; } export interface WebhookConfig { @@ -194,6 +199,7 @@ export interface FleetTemplate { export interface FleetConfig { channel?: ChannelConfig; + channels?: ChannelConfig[]; project_roots?: string[]; defaults: FleetDefaults; instances: Record; diff --git a/src/web-api.ts b/src/web-api.ts index eeaf6462..04c417f1 100644 --- a/src/web-api.ts +++ b/src/web-api.ts @@ -9,6 +9,104 @@ import { fileURLToPath } from "node:url"; import { execFileSync } from "node:child_process"; import type { LifecycleCreateArgs } from "./instance-lifecycle.js"; import { CreateInstanceArgs, validateArgs } from "./outbound-schemas.js"; +import { z } from "zod"; + +// ── Strict public-facing schemas ──────────────────────────────────────────── +// web-api endpoints must reject unknown fields so the dashboard cannot inject +// internal-only flags that would reach handleCreate/scheduler/config writers. + +const MAX_TEXT = 16_384; + +const TaskCreateSchema = z.object({ + title: z.string().min(1).max(512), + description: z.string().max(MAX_TEXT).optional(), + priority: z.enum(["low", "normal", "high", "urgent"]).optional(), + assignee: z.string().max(128).optional(), +}).strict(); + +const TaskUpdateSchema = z.object({ + action: z.enum(["claim", "complete", "update"]).optional(), + assignee: z.string().max(128).optional(), + result: z.string().max(MAX_TEXT).optional(), + status: z.string().max(64).optional(), + title: z.string().max(512).optional(), + description: z.string().max(MAX_TEXT).optional(), + priority: z.string().max(64).optional(), +}).strict(); + +const ScheduleCreateSchema = z.object({ + cron: z.string().min(1).max(256), + message: z.string().min(1).max(MAX_TEXT), + target: z.string().min(1).max(256), + label: z.string().max(256).optional(), + timezone: z.string().max(128).optional(), +}).strict(); + +const TeamCreateSchema = z.object({ + name: z.string().min(1).max(128).regex(/^[A-Za-z0-9._-]+$/), + members: z.array(z.string().min(1).max(256)).min(1).max(256), + description: z.string().max(MAX_TEXT).optional(), +}).strict(); + +const ConfigUpdateSchema = z.object({ + channel: z.object({ + group_id: z.union([z.number(), z.string()]).optional(), + access: z.record(z.string(), z.unknown()).optional(), + }).strict().optional(), + defaults: z.object({ + backend: z.enum(["claude-code", "gemini-cli", "codex", "opencode", "kiro-cli", "antigravity"]).optional(), + model: z.string().max(128).optional(), + }).strict().optional(), + project_roots: z.array(z.string().min(1).max(1024)).max(64).optional(), +}).strict(); + +const SendMessageSchema = z.object({ + instance: z.string().min(1).max(128), + message: z.string().min(1).max(MAX_TEXT), +}).strict(); + +/** + * Push a single SSE frame to every client. If a client throws (closed socket + * after a network drop, etc.), evict it from the set and continue — without + * the eviction the dead entry leaks forever, and without the try/catch a + * single dead client breaks delivery to every client iterated after it. + */ +export function broadcastSseEvent( + clients: Set, + event: string, + data: unknown, + onError?: (err: unknown) => void, +): void { + const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; + const dead: ServerResponse[] = []; + for (const client of clients) { + try { + client.write(payload); + } catch (err) { + dead.push(client); + onError?.(err); + } + } + for (const c of dead) { + clients.delete(c); + try { c.end(); } catch { /* socket already gone */ } + } +} + +function parseOrReject( + schema: z.ZodType, + data: unknown, + res: ServerResponse, +): T | null { + const r = schema.safeParse(data); + if (!r.success) { + const issue = r.error.issues[0]; + const path = issue.path.join("."); + json(res, 400, { error: `${path || "body"}: ${issue.message}` }); + return null; + } + return r.data; +} const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -25,6 +123,8 @@ export interface WebApiContext { } | null; readonly instanceIpcClients: Map; readonly adapter: { sendText(chatId: string, text: string, opts?: { threadId?: string }): Promise } | null; + getAdapterForInstance?(name: string): { sendText(chatId: string, text: string, opts?: { threadId?: string }): Promise } | null; + getGroupIdForInstance?(name: string): string; readonly daemons: Map; readonly eventLog: { logActivity(event: string, sender: string, summary: string, receiver?: string, detail?: string): void; listActivity(opts?: { since?: string; limit?: number }): unknown[] } | null; readonly logger: { info(obj: unknown, msg?: string): void; debug(obj: unknown, msg?: string): void; error(obj: unknown, msg?: string): void }; @@ -159,12 +259,25 @@ export function handleWebRequest( res.write(`event: status\ndata: ${JSON.stringify(ctx.getUiStatus())}\n\n`); ctx.sseClients.add(res); const interval = setInterval(() => { - res.write(`event: status\ndata: ${JSON.stringify(ctx.getUiStatus())}\n\n`); + try { + res.write(`event: status\ndata: ${JSON.stringify(ctx.getUiStatus())}\n\n`); + } catch { + cleanup(); + } }, 10_000); - req.on("close", () => { + let cleanedUp = false; + const cleanup = (): void => { + if (cleanedUp) return; + cleanedUp = true; ctx.sseClients.delete(res); clearInterval(interval); - }); + }; + // `close` covers normal disconnects; `error` covers network resets that + // never deliver a clean FIN. Without both, dead clients accumulate in + // sseClients and the heartbeat interval keeps firing forever. + req.on("close", cleanup); + req.on("error", cleanup); + res.on("error", cleanup); return true; } @@ -334,11 +447,13 @@ export function handleWebRequest( (async () => { try { const body = await parseBody(req); + const parsed = parseOrReject(TaskCreateSchema, body, res); + if (!parsed) return; const task = ctx.scheduler!.db.createTask({ - title: body.title as string, - description: body.description as string | undefined, - priority: body.priority as string | undefined, - assignee: body.assignee as string | undefined, + title: parsed.title, + description: parsed.description, + priority: parsed.priority, + assignee: parsed.assignee, created_by: "web-user", }); json(res, 200, task); @@ -359,14 +474,17 @@ export function handleWebRequest( (async () => { try { const body = await parseBody(req); - const action = body.action as string; + const parsed = parseOrReject(TaskUpdateSchema, body, res); + if (!parsed) return; let result: unknown; - if (action === "claim") { - result = ctx.scheduler!.db.claimTask(id, (body.assignee as string) || "web-user"); - } else if (action === "complete") { - result = ctx.scheduler!.db.completeTask(id, body.result as string | undefined); + if (parsed.action === "claim") { + result = ctx.scheduler!.db.claimTask(id, parsed.assignee || "web-user"); + } else if (parsed.action === "complete") { + result = ctx.scheduler!.db.completeTask(id, parsed.result); } else { - result = ctx.scheduler!.db.updateTask(id, body); + // Strip action before passing remaining fields to updateTask + const { action: _a, ...rest } = parsed; + result = ctx.scheduler!.db.updateTask(id, rest); } json(res, 200, result); } catch (err) { @@ -389,7 +507,9 @@ export function handleWebRequest( (async () => { try { const body = await parseBody(req); - const schedule = ctx.scheduler!.create(body); + const parsed = parseOrReject(ScheduleCreateSchema, body, res); + if (!parsed) return; + const schedule = ctx.scheduler!.create(parsed); json(res, 200, schedule); } catch (err) { json(res, 400, { error: (err as Error).message }); } })(); @@ -418,15 +538,16 @@ export function handleWebRequest( (async () => { try { const body = await parseBody(req); - const name = body.name as string; - const members = body.members as string[]; - const description = body.description as string | undefined; - if (!name || !members?.length) { json(res, 400, { error: "name and members required" }); return; } + const parsed = parseOrReject(TeamCreateSchema, body, res); + if (!parsed) return; if (!ctx.fleetConfig) { json(res, 500, { error: "No fleet config" }); return; } if (!ctx.fleetConfig.teams) (ctx.fleetConfig as { teams: Record }).teams = {}; - (ctx.fleetConfig.teams as Record)[name] = { members, description }; + (ctx.fleetConfig.teams as Record)[parsed.name] = { + members: parsed.members, + description: parsed.description, + }; ctx.saveFleetConfig(); - json(res, 200, { created: name }); + json(res, 200, { created: parsed.name }); } catch (err) { json(res, 400, { error: (err as Error).message }); } })(); return true; @@ -467,28 +588,28 @@ export function handleWebRequest( (async () => { try { const body = await parseBody(req); + const parsed = parseOrReject(ConfigUpdateSchema, body, res); + if (!parsed) return; const config = ctx.fleetConfig; if (!config) { json(res, 500, { error: "No fleet config" }); return; } const ch = config.channel as Record | undefined; // Update channel settings - if (body.channel && ch) { - const update = body.channel as Record; - if (update.group_id != null) (config.channel as Record).group_id = update.group_id; - if (update.access) (config.channel as Record).access = update.access; + if (parsed.channel && ch) { + if (parsed.channel.group_id != null) (config.channel as Record).group_id = parsed.channel.group_id; + if (parsed.channel.access) (config.channel as Record).access = parsed.channel.access; } // Update defaults - if (body.defaults) { + if (parsed.defaults) { const d = (config as Record).defaults as Record; - const upd = body.defaults as Record; - if (upd.backend) d.backend = upd.backend; - if (upd.model) d.model = upd.model; + if (parsed.defaults.backend) d.backend = parsed.defaults.backend; + if (parsed.defaults.model) d.model = parsed.defaults.model; } // Update project_roots - if (body.project_roots) { - (config as Record).project_roots = body.project_roots; + if (parsed.project_roots) { + (config as Record).project_roots = parsed.project_roots; } ctx.saveFleetConfig(); - const needsRestart = !!(body.channel as Record)?.group_id; + const needsRestart = parsed.channel?.group_id != null; json(res, 200, { saved: true, needs_restart: needsRestart }); } catch (err) { json(res, 400, { error: (err as Error).message }); } })(); @@ -503,10 +624,26 @@ export function handleWebRequest( /** Handle POST /ui/send — extracted for readability. */ function handleSendMessage(req: IncomingMessage, res: ServerResponse, ctx: WebApiContext): void { let body = ""; - req.on("data", (chunk: Buffer) => { body += chunk.toString(); }); + let size = 0; + req.on("data", (chunk: Buffer) => { + size += chunk.length; + if (size > MAX_TEXT * 2) { + // Stop accumulating early on obviously oversized bodies. + req.destroy(); + return; + } + body += chunk.toString(); + }); req.on("end", () => { try { - const { instance, message } = JSON.parse(body) as { instance: string; message: string }; + const raw = JSON.parse(body); + const parsed = SendMessageSchema.safeParse(raw); + if (!parsed.success) { + const issue = parsed.error.issues[0]; + json(res, 400, { error: `${issue.path.join(".") || "body"}: ${issue.message}` }); + return; + } + const { instance, message } = parsed.data; const ipc = ctx.instanceIpcClients.get(instance); if (!ipc) { json(res, 404, { error: `Instance not found: ${instance}` }); @@ -535,15 +672,17 @@ function handleSendMessage(req: IncomingMessage, res: ServerResponse, ctx: WebAp ctx.lastInboundUser.set(instance, "web-user"); ctx.eventLog?.logActivity("message", "web-user", message.slice(0, 200), instance); ctx.emitSseEvent("message", { instance, sender: "web-user", text: message, ts }); - // Sync to Telegram - if (ctx.adapter && ctx.fleetConfig?.channel?.group_id) { - const topicId = ctx.fleetConfig.instances[instance]?.topic_id; + // Sync to Telegram/Discord + const syncAdapter = ctx.getAdapterForInstance?.(instance) ?? ctx.adapter; + const syncGroupId = ctx.getGroupIdForInstance?.(instance) ?? String(ctx.fleetConfig?.channel?.group_id ?? ""); + if (syncAdapter && syncGroupId) { + const topicId = ctx.fleetConfig?.instances[instance]?.topic_id; const preview = message.length > 500 ? message.slice(0, 500) + " [...]" : message; - ctx.adapter.sendText( - String(ctx.fleetConfig.channel.group_id), + syncAdapter.sendText( + syncGroupId, `🌐 web-user: ${preview}`, { threadId: topicId != null ? String(topicId) : undefined }, - ).catch(e => ctx.logger.debug({ err: e }, "Web→Telegram sync failed")); + ).catch(() => ctx.logger.debug({}, "Web→Channel sync failed")); } json(res, 200, { sent: true }); } catch { diff --git a/src/workflow-templates/default.md b/src/workflow-templates/default.md index a2e12f67..33da9aac 100644 --- a/src/workflow-templates/default.md +++ b/src/workflow-templates/default.md @@ -12,7 +12,7 @@ ## Shared Decisions -- Run `list_decisions` after context rotation to reload fleet-wide decisions. +- Run `list_decisions` after restart to reload fleet-wide decisions. - Use `post_decision` to share architectural choices that affect other instances. ## Progress Tracking @@ -24,7 +24,11 @@ Use the **Task Board** (`task` tool) for multi-step work: ## Context Protection +- **Images**: Always use subagents to read/analyze images. Never read image files directly in your main context — they consume massive token budget. Delegate image reading to a subagent and receive only the text summary back. - **Large searches**: use subagents (Agent tool) instead of reading many files directly - **Big codebases**: glob/grep for specific targets, don't read entire directories - **Long conversations**: summarize decisions into Shared Decisions before context fills up -- Watch your context usage; when it's high, wrap up current work and let context rotation handle the rest + +## On Startup + +- **Read all steering files first**: On startup, immediately read all files in `.kiro/steering/` (or equivalent) to load your full skill set and role context. Do not wait for a task to trigger reading them — proactively load all available knowledge before responding to any message. diff --git a/templates/systemd.service.ejs b/templates/systemd.service.ejs index d7c34513..ff1b0782 100644 --- a/templates/systemd.service.ejs +++ b/templates/systemd.service.ejs @@ -3,12 +3,17 @@ Description=AgEnD — AI Engineering Daemon After=network.target [Service] -Type=simple +Type=notify +NotifyAccess=all +TimeoutStartSec=0 +WatchdogSec=60 ExecStart=<%= execPath %> fleet start Environment=PATH=<%= path %> Environment=TERM=xterm-256color WorkingDirectory=<%= workingDirectory %> -Restart=no +Restart=on-failure +RestartSec=5 +TimeoutStopSec=300 StandardOutput=append:<%= logPath %> StandardError=append:<%= logPath %> diff --git a/tests/channel/adapters/telegram.test.ts b/tests/channel/adapters/telegram.test.ts index a8bca021..fdaa6d93 100644 --- a/tests/channel/adapters/telegram.test.ts +++ b/tests/channel/adapters/telegram.test.ts @@ -80,7 +80,20 @@ vi.mock("grammy", () => { // Expose handlers map so tests can fire them (globalThis as Record).__grammyHandlers = handlers; - return { Bot: MockBot, InputFile: MockInputFile, InlineKeyboard: MockInlineKeyboard }; + class MockGrammyError extends Error { + error_code: number; + constructor(message: string, error_code: number) { + super(message); + this.error_code = error_code; + } + } + + return { + Bot: MockBot, + InputFile: MockInputFile, + InlineKeyboard: MockInlineKeyboard, + GrammyError: MockGrammyError, + }; }); // ── Imports after mock ──────────────────────────────────────────────────── @@ -177,6 +190,34 @@ describe("TelegramAdapter", () => { await expect(adapter.stop()).resolves.toBeUndefined(); }); + it("gives up after MAX_409_RETRIES on persistent 409 conflict", async () => { + vi.useFakeTimers(); + try { + const grammy = await import("grammy"); + const GrammyError = (grammy as unknown as { GrammyError: new (m: string, c: number) => Error & { error_code: number } }).GrammyError; + const bot = (adapter as unknown as { bot: { start: ReturnType } }).bot; + bot.start = vi.fn(() => Promise.reject(new GrammyError("conflict", 409))); + + const errors: Error[] = []; + const conflicts: number[] = []; + adapter.on("error", (e: Error) => errors.push(e)); + adapter.on("polling_conflict", ({ attempt }: { attempt: number }) => conflicts.push(attempt)); + + await adapter.start(); + // Drain the 409 backoff loop (cap is 30 retries; max delay is 15s each) + for (let i = 0; i < 35; i++) { + await vi.advanceTimersByTimeAsync(15_000); + } + + expect(bot.start).toHaveBeenCalledTimes(30); + expect(conflicts).toHaveLength(29); // emitted before each delay; final attempt skips delay + expect(errors).toHaveLength(1); + expect(errors[0].message).toMatch(/giving up/); + } finally { + vi.useRealTimers(); + } + }); + // ── sendText ───────────────────────────────────────────────────────────── it("sendText sends a message and returns SentMessage", async () => { @@ -321,10 +362,17 @@ describe("TelegramAdapter", () => { expect(code).toBe("AABBCC"); }); - it("confirmPairing delegates to AccessManager.confirmCode", async () => { + it("confirmPairing delegates to AccessManager.confirmCode with callerUserId", async () => { + const spy = vi.spyOn(am, "confirmCode").mockReturnValue(true); + const result = await adapter.confirmPairing("AABBCC", "999"); + expect(spy).toHaveBeenCalledWith("AABBCC", "999"); + expect(result).toBe(true); + }); + + it("confirmPairing forwards undefined callerUserId when omitted", async () => { const spy = vi.spyOn(am, "confirmCode").mockReturnValue(true); const result = await adapter.confirmPairing("AABBCC"); - expect(spy).toHaveBeenCalledWith("AABBCC"); + expect(spy).toHaveBeenCalledWith("AABBCC", undefined); expect(result).toBe(true); }); diff --git a/tests/channel/ipc-bridge.test.ts b/tests/channel/ipc-bridge.test.ts index 4f58b019..e3c23202 100644 --- a/tests/channel/ipc-bridge.test.ts +++ b/tests/channel/ipc-bridge.test.ts @@ -67,6 +67,31 @@ describe("IPC Bridge", () => { await client.connect(); // should work }); + it("drops client when a single line exceeds the 1 MB buffer cap", async () => { + tmpDir = join(tmpdir(), `ccd-ipc-${Date.now()}`); + mkdirSync(tmpDir, { recursive: true }); + const sockPath = join(tmpDir, "test.sock"); + + server = new IpcServer(sockPath); + const received: unknown[] = []; + server.on("message", (msg) => received.push(msg)); + await server.listen(); + + client = new IpcClient(sockPath); + let disconnected = false; + client.on("disconnect", () => { disconnected = true; }); + await client.connect(); + + // Server-side: write a payload exceeding 1 MB on a single line (no \n) + // to trigger the line-parser overflow path. + const huge = "x".repeat(1_100_000); + server.broadcast({ type: "evil", payload: huge }); + + await new Promise(r => setTimeout(r, 200)); + expect(received).toHaveLength(0); // overflow before parse + expect(disconnected).toBe(true); // client socket destroyed + }); + it("rejects socket path exceeding OS limit", async () => { tmpDir = join(tmpdir(), `ccd-ipc-${Date.now()}`); mkdirSync(tmpDir, { recursive: true }); diff --git a/tests/channel/message-queue.test.ts b/tests/channel/message-queue.test.ts index 9b29acef..e5364a35 100644 --- a/tests/channel/message-queue.test.ts +++ b/tests/channel/message-queue.test.ts @@ -129,6 +129,58 @@ describe("MessageQueue", () => { expect(callCount).toBeGreaterThanOrEqual(2); }); + it("flood control drop also resets backoff (P3.8)", async () => { + // Reproduces the P3.8 bug: under sustained 429s, backoff grew past 10s, + // status_updates were dropped, but backoff stayed high — so even after + // shedding load the queue waited a full ~30s between retries. + let n429 = 0; + const sent: string[] = []; + const sendFn = vi.fn(async (_c: string, _t: string | undefined, text: string) => { + // Throw 429 enough times to push backoff past FLOOD_CONTROL_THRESHOLD_MS (10s). + // Backoff doubles 1→2→4→8→16: the 5th failure makes backoffMs = 16s. + if (n429 < 5) { + n429++; + const err = new Error("Too Many Requests") as Error & { status?: number }; + err.status = 429; + throw err; + } + sent.push(text); + return { messageId: "m" }; + }); + const warnSpy = vi.fn(); + const queue = new MessageQueue( + { send: sendFn, edit: vi.fn(), sendFile: vi.fn(async () => ({ messageId: "f" })) }, + { warn: warnSpy }, + ); + + queue.enqueue("c1", undefined, { type: "content", text: "important" }); + for (let i = 0; i < 50; i++) { + queue.enqueue("c1", undefined, { type: "status_update", text: `status-${i}` }); + } + queue.start(); + + // Allow time for: 5 failures (cumulative backoff ≈ 1+2+4+8 = 15s of waits if + // not reset). After flood drop resets backoff to 1s, a 6th attempt should + // succeed within ~1s. We give the queue a generous window but well under + // the unbounded-backoff worst case. + await new Promise(r => setTimeout(r, 20_000)); + queue.stop(); + + // Flood control should have logged at least once. + const floodWarn = warnSpy.mock.calls.find(c => + String((c[1] ?? c[0]?.msg ?? "")).includes("flood control") + || String(c[1] ?? "").includes("flood control"), + ); + expect(floodWarn).toBeDefined(); + + // Surviving content should have been delivered. + expect(sent).toContain("important"); + + // The 50 status_updates should have been mostly dropped, not all delivered. + const statusSent = sent.filter(t => t.startsWith("status-")).length; + expect(statusSent).toBeLessThan(50); + }, 30_000); + it("drops status_update items during flood control (backoff > 10s)", async () => { const sent: string[] = []; let callCount = 0; diff --git a/tests/cost-guard.test.ts b/tests/cost-guard.test.ts index 4636475c..83b868e4 100644 --- a/tests/cost-guard.test.ts +++ b/tests/cost-guard.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { mkdirSync, rmSync } from "node:fs"; -import { CostGuard } from "../src/cost-guard.js"; +import { CostGuard, msUntilMidnight } from "../src/cost-guard.js"; import { EventLog } from "../src/event-log.js"; import type { CostGuardConfig } from "../src/types.js"; @@ -136,13 +136,111 @@ describe("CostGuard", () => { guardDisabled.stop(); }); + it("re-emits limit after rotation if accumulated still exceeds (P2.2)", () => { + // Reproduces the bug where a user manually restarts a paused instance + // and the new session burns through the daily budget unannounced. + const limitSpy = vi.fn(); + guard.on("limit", limitSpy); + + // Session 1: blow past $10 limit → emit + (in real fleet) instance paused + guard.updateCost("agent1", 10.50); + expect(limitSpy).toHaveBeenCalledTimes(1); + + // User manually restarts → new session reports cost=0 → rotation detected + guard.updateCost("agent1", 0); + // Then ramps the new session up past the (already-exceeded) accumulated cap + guard.updateCost("agent1", 0.50); + + // Should re-emit so the fleet can re-pause the instance + expect(limitSpy).toHaveBeenCalledTimes(2); + }); + + it("re-emits warn after rotation if accumulated still above warn threshold (P2.2)", () => { + const warnSpy = vi.fn(); + guard.on("warn", warnSpy); + + guard.updateCost("agent1", 8.50); // warn at 80% of $10 + expect(warnSpy).toHaveBeenCalledTimes(1); + + // Rotation + guard.updateCost("agent1", 0); + guard.updateCost("agent1", 0.10); + + // accumulated $8.50 + new $0.10 = $8.60, still > warn threshold $8 + expect(warnSpy).toHaveBeenCalledTimes(2); + }); + + it("does not re-emit on rotation if accumulated drops below threshold (sanity)", () => { + // If the next session doesn't push us back over the threshold, no re-emit. + const warnSpy = vi.fn(); + guard.on("warn", warnSpy); + + // Session 1: under warn threshold + guard.updateCost("agent1", 5.00); + expect(warnSpy).not.toHaveBeenCalled(); + + // Rotation, low new session + guard.updateCost("agent1", 0); + guard.updateCost("agent1", 1.00); + + // accumulated $5 + $1 = $6, below $8 warn → still no emit + expect(warnSpy).not.toHaveBeenCalled(); + }); + it("schedules midnight reset via startMidnightReset", () => { const resetSpy = vi.fn(); guard.on("daily_reset", resetSpy); guard.startMidnightReset(); - // Advance past 24 hours to ensure midnight fires - vi.advanceTimersByTime(25 * 60 * 60 * 1000); + // 26h covers the worst-case 25-hour fall-back day + vi.advanceTimersByTime(26 * 60 * 60 * 1000); expect(resetSpy).toHaveBeenCalled(); guard.stop(); }); }); + +describe("msUntilMidnight (DST handling — P2.8)", () => { + beforeEach(() => { vi.useFakeTimers(); }); + afterEach(() => { vi.useRealTimers(); }); + + it("returns time until next UTC midnight on a normal day", () => { + // 2025-06-15 23:30:00 UTC — 30 minutes until next UTC midnight + vi.setSystemTime(new Date("2025-06-15T23:30:00Z")); + const ms = msUntilMidnight("UTC"); + expect(ms).toBeGreaterThan(29 * 60 * 1000); + expect(ms).toBeLessThanOrEqual(30 * 60 * 1000 + 1); + }); + + it("spring-forward: returns 21.5h (not the buggy 22.5h) at LA pre-jump 01:30 PST", () => { + // 2025-03-09 09:30 UTC = LA 01:30 PST (pre-jump; LA jumps at 10:00 UTC). + // Next LA midnight = 2025-03-10 00:00 PDT = 2025-03-10 07:00 UTC. + // Real ms = 21.5 hours. Old code returned 22.5h because its fake-local + // arithmetic doesn't notice the DST hour we're about to lose. + vi.setSystemTime(new Date("2025-03-09T09:30:00Z")); + const ms = msUntilMidnight("America/Los_Angeles"); + expect(ms).toBeGreaterThan(21 * 60 * 60 * 1000 + 29 * 60 * 1000); + expect(ms).toBeLessThanOrEqual(21 * 60 * 60 * 1000 + 30 * 60 * 1000 + 1); + }); + + it("fall-back: returns 23.5h (not the buggy 22.5h) at LA pre-fallback 01:30 PDT", () => { + // 2025-11-02 08:30 UTC = LA 01:30 PDT (pre-fallback; LA falls back at 09:00 UTC). + // Next LA midnight = 2025-11-03 00:00 PST = 2025-11-03 08:00 UTC. + // Real ms = 23.5 hours. Old code returned 22.5h because its fake-local + // arithmetic doesn't notice the DST hour we're about to gain. + vi.setSystemTime(new Date("2025-11-02T08:30:00Z")); + const ms = msUntilMidnight("America/Los_Angeles"); + expect(ms).toBeGreaterThan(23 * 60 * 60 * 1000 + 29 * 60 * 1000); + expect(ms).toBeLessThanOrEqual(23 * 60 * 60 * 1000 + 30 * 60 * 1000 + 1); + }); + + it("never returns more than 26 hours", () => { + vi.setSystemTime(new Date("2025-01-15T00:00:00Z")); + expect(msUntilMidnight("UTC")).toBeLessThanOrEqual(26 * 60 * 60 * 1000); + expect(msUntilMidnight("Pacific/Kiritimati")).toBeLessThanOrEqual(26 * 60 * 60 * 1000); + expect(msUntilMidnight("Pacific/Pago_Pago")).toBeLessThanOrEqual(26 * 60 * 60 * 1000); + }); + + it("always returns a positive value", () => { + vi.setSystemTime(new Date("2025-06-15T23:59:59.999Z")); + expect(msUntilMidnight("UTC")).toBeGreaterThan(0); + }); +}); diff --git a/tests/export-import.test.ts b/tests/export-import.test.ts index e6855876..9feb715f 100644 --- a/tests/export-import.test.ts +++ b/tests/export-import.test.ts @@ -104,6 +104,39 @@ describe("ccd import", () => { expect(files.some((f) => f.startsWith(".env.bak."))).toBe(true); }); + it("rejects archive with traversal entries (zip-slip)", async () => { + const { importConfig } = await import("../src/export-import.js"); + + const dstDir = makeDataDir("import-slip-dst"); + const evilStaging = join(TMP, "evil"); + mkdirSync(join(evilStaging, ".agend"), { recursive: true }); + writeFileSync(join(evilStaging, "pwned.txt"), "owned"); + + // Build a tar whose entry path escapes the target dataDir. + const tarFile = join(TMP, "slip.tar.gz"); + execSync(`tar czf "${tarFile}" -C "${evilStaging}" ".agend" "pwned.txt"`); + + const origExit = process.exit; + const origErr = console.error; + let exited = false; + process.exit = ((code?: number) => { + exited = true; + throw new Error(`exit ${code ?? 0}`); + }) as never; + console.error = () => {}; + try { + await importConfig(dstDir, tarFile); + } catch { + // expected + } + process.exit = origExit; + console.error = origErr; + + expect(exited).toBe(true); + // And no stray file escaped. + expect(existsSync(join(TMP, "pwned.txt"))).toBe(false); + }); + it("warns about missing paths after import", async () => { const { exportConfig, importConfig } = await import( "../src/export-import.js" diff --git a/tests/integration-e2e.test.ts b/tests/integration-e2e.test.ts index d05036e3..24d7ac5c 100644 --- a/tests/integration-e2e.test.ts +++ b/tests/integration-e2e.test.ts @@ -63,6 +63,7 @@ function mockIpc() { } const defaultAnswers: WizardAnswers = { + channelType: "telegram", backend: "claude-code", botTokenEnv: "TEST_BOT_TOKEN", groupId: -1001234567890, diff --git a/tests/resolve-access-path.test.ts b/tests/resolve-access-path.test.ts index 471842dc..3d199a8d 100644 --- a/tests/resolve-access-path.test.ts +++ b/tests/resolve-access-path.test.ts @@ -14,4 +14,27 @@ describe("resolveAccessPathFromConfig", () => { const result = resolveAccessPathFromConfig(dataDir, "my-inst", undefined); expect(result).toBe(join(dataDir, "instances", "my-inst", "access.json")); }); + + it("rejects instance names that contain path traversal segments (P4.3)", () => { + expect(() => resolveAccessPathFromConfig(dataDir, "..", undefined)).toThrow(/Invalid instance name/); + expect(() => resolveAccessPathFromConfig(dataDir, ".", undefined)).toThrow(/Invalid instance name/); + expect(() => resolveAccessPathFromConfig(dataDir, "../etc", undefined)).toThrow(/Invalid instance name/); + expect(() => resolveAccessPathFromConfig(dataDir, "a/b", undefined)).toThrow(/Invalid instance name/); + expect(() => resolveAccessPathFromConfig(dataDir, "a\\b", undefined)).toThrow(/Invalid instance name/); + expect(() => resolveAccessPathFromConfig(dataDir, "", undefined)).toThrow(/Invalid instance name/); + expect(() => resolveAccessPathFromConfig(dataDir, "a\0b", undefined)).toThrow(/Invalid instance name/); + }); + + it("topic mode does not require instance validation (instance is unused)", () => { + // Topic mode doesn't embed instance in the path, so it tolerates anything + // — validation only applies to the per-instance branch. + const result = resolveAccessPathFromConfig(dataDir, "..", { mode: "topic" }); + expect(result).toBe(join(dataDir, "access", "access.json")); + }); + + it("accepts conventional instance names with letters/digits/_-.", () => { + expect(() => resolveAccessPathFromConfig(dataDir, "main", undefined)).not.toThrow(); + expect(() => resolveAccessPathFromConfig(dataDir, "my-inst_2", undefined)).not.toThrow(); + expect(() => resolveAccessPathFromConfig(dataDir, "v1.0-main", undefined)).not.toThrow(); + }); }); diff --git a/tests/scheduler-catchup.test.ts b/tests/scheduler-catchup.test.ts new file mode 100644 index 00000000..d39cae64 --- /dev/null +++ b/tests/scheduler-catchup.test.ts @@ -0,0 +1,146 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { mkdirSync, rmSync } from "node:fs"; +import Database from "better-sqlite3"; +import { Scheduler } from "../src/scheduler/scheduler.js"; +import { DEFAULT_SCHEDULER_CONFIG, type Schedule } from "../src/scheduler/types.js"; + +/** + * Seed last_triggered_at directly via raw sqlite — Scheduler/SchedulerDb + * only update it through recordRun(datetime('now')), so to simulate a + * missed-fire scenario we have to backdate the row ourselves. + */ +function setLastTriggered(dbPath: string, id: string, iso: string | null): void { + const raw = new Database(dbPath); + raw.prepare("UPDATE schedules SET last_triggered_at = ? WHERE id = ?").run(iso, id); + raw.close(); +} + +function setCreatedAt(dbPath: string, id: string, iso: string): void { + const raw = new Database(dbPath); + raw.prepare("UPDATE schedules SET created_at = ? WHERE id = ?").run(iso, id); + raw.close(); +} + +function setEnabled(dbPath: string, id: string, enabled: boolean): void { + const raw = new Database(dbPath); + raw.prepare("UPDATE schedules SET enabled = ? WHERE id = ?").run(enabled ? 1 : 0, id); + raw.close(); +} + +describe("Scheduler — catch-up on init", () => { + let tmpDir: string; + let dbPath: string; + let fired: Schedule[]; + let scheduler: Scheduler | null; + + beforeEach(() => { + tmpDir = join(tmpdir(), `sched-catchup-${Date.now()}-${Math.random()}`); + mkdirSync(tmpDir, { recursive: true }); + dbPath = join(tmpDir, "scheduler.db"); + fired = []; + scheduler = null; + }); + + afterEach(() => { + scheduler?.shutdown(); + rmSync(tmpDir, { recursive: true, force: true }); + }); + + function makeScheduler(): Scheduler { + return new Scheduler( + dbPath, + (s) => { fired.push(s); }, + DEFAULT_SCHEDULER_CONFIG, + () => true, + ); + } + + function createSchedule(cron: string): Schedule { + const seed = makeScheduler(); + const s = seed.create({ + cron, + message: "ping", + source: "test", + target: "general", + reply_chat_id: "chat-1", + reply_thread_id: null, + }); + seed.shutdown(); + return s; + } + + it("fires once for a schedule whose expected run was missed within the window", () => { + const s = createSchedule("* * * * *"); // every minute + // Last fire was 5 minutes ago → next expected ~4 minutes ago → missed + const fiveMinAgo = new Date(Date.now() - 5 * 60 * 1000).toISOString(); + setLastTriggered(dbPath, s.id, fiveMinAgo); + + scheduler = makeScheduler(); + scheduler.init(); + + expect(fired).toHaveLength(1); + expect(fired[0].id).toBe(s.id); + }); + + it("does NOT fire if the missed run is older than the 24h catch-up window", () => { + const s = createSchedule("0 9 * * *"); // 9am daily + // Last fire was 3 days ago → next expected 2 days ago → way past 24h + const threeDaysAgo = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString(); + setLastTriggered(dbPath, s.id, threeDaysAgo); + + scheduler = makeScheduler(); + scheduler.init(); + + expect(fired).toHaveLength(0); + }); + + it("does NOT fire if the next expected run is still in the future", () => { + const s = createSchedule("0 9 * * *"); // 9am daily + // Last fire was just now → next expected tomorrow 9am → not due + setLastTriggered(dbPath, s.id, new Date().toISOString()); + + scheduler = makeScheduler(); + scheduler.init(); + + expect(fired).toHaveLength(0); + }); + + it("skips disabled schedules", () => { + const s = createSchedule("* * * * *"); + const fiveMinAgo = new Date(Date.now() - 5 * 60 * 1000).toISOString(); + setLastTriggered(dbPath, s.id, fiveMinAgo); + setEnabled(dbPath, s.id, false); + + scheduler = makeScheduler(); + scheduler.init(); + + expect(fired).toHaveLength(0); + }); + + it("uses created_at when last_triggered_at is null", () => { + const s = createSchedule("* * * * *"); + // Never triggered; backdate creation by 5 minutes + setLastTriggered(dbPath, s.id, null); + setCreatedAt(dbPath, s.id, new Date(Date.now() - 5 * 60 * 1000).toISOString()); + + scheduler = makeScheduler(); + scheduler.init(); + + expect(fired).toHaveLength(1); + expect(fired[0].id).toBe(s.id); + }); + + it("fires at most once per schedule, even when many minutes were missed", () => { + const s = createSchedule("* * * * *"); // every minute + // Last fire was 60 minutes ago → 59 minute fires were missed + const hourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString(); + setLastTriggered(dbPath, s.id, hourAgo); + + scheduler = makeScheduler(); + scheduler.init(); + + expect(fired).toHaveLength(1); + }); +}); diff --git a/tests/service-installer.test.ts b/tests/service-installer.test.ts index a3517014..c3cc7ba2 100644 --- a/tests/service-installer.test.ts +++ b/tests/service-installer.test.ts @@ -37,4 +37,32 @@ describe("ServiceInstaller", () => { expect(plist).toContain("PATH"); expect(plist).toContain(process.env.PATH!); }); + + it("rejects logPath with newline (systemd directive injection)", () => { + expect(() => renderSystemdUnit({ + ...vars, + logPath: "/tmp/log\nExecStartPost=/bin/rm -rf /", + })).toThrow(/control characters/); + }); + + it("rejects workingDirectory with NUL", () => { + expect(() => renderLaunchdPlist({ + ...vars, + workingDirectory: "/tmp/\x00escape", + })).toThrow(/control characters/); + }); + + it("rejects non-absolute execPath", () => { + expect(() => renderSystemdUnit({ + ...vars, + execPath: "agend", + })).toThrow(/absolute path/); + }); + + it("rejects label containing special chars", () => { + expect(() => renderLaunchdPlist({ + ...vars, + label: "com.agend; /bin/sh", + })).toThrow(/label must match/); + }); }); diff --git a/tests/setup-wizard-config.test.ts b/tests/setup-wizard-config.test.ts index bdf9102a..3ad09819 100644 --- a/tests/setup-wizard-config.test.ts +++ b/tests/setup-wizard-config.test.ts @@ -7,6 +7,7 @@ import { buildFleetConfig, type WizardAnswers } from "../src/setup-wizard.js"; import { AccessManager } from "../src/channel/access-manager.js"; const defaults: WizardAnswers = { + channelType: "telegram", backend: "claude-code", botTokenEnv: "AGEND_BOT_TOKEN", groupId: -1001234567890, diff --git a/tests/sse.test.ts b/tests/sse.test.ts new file mode 100644 index 00000000..e943a8f9 --- /dev/null +++ b/tests/sse.test.ts @@ -0,0 +1,178 @@ +import { describe, it, expect, vi } from "vitest"; +import { EventEmitter } from "node:events"; +import { ServerResponse, type IncomingMessage } from "node:http"; +import { Readable } from "node:stream"; +import { broadcastSseEvent, handleWebRequest, type WebApiContext } from "../src/web-api.js"; + +// ── Fake ServerResponse-shaped client for broadcastSseEvent ──────────────── + +interface FakeClient { + written: string[]; + ended: boolean; + failNext?: boolean; + write(payload: string): boolean; + end(): void; +} + +function makeFakeClient(failNext = false): FakeClient { + return { + written: [], + ended: false, + failNext, + write(payload: string) { + if (this.failNext) throw new Error("EPIPE"); + this.written.push(payload); + return true; + }, + end() { this.ended = true; }, + }; +} + +describe("broadcastSseEvent", () => { + it("writes one SSE frame to every healthy client", () => { + const a = makeFakeClient(); + const b = makeFakeClient(); + const set = new Set([a, b] as unknown as Iterable); + + broadcastSseEvent(set as Set, "status", { ok: true }); + + expect(a.written).toHaveLength(1); + expect(a.written[0]).toContain("event: status"); + expect(a.written[0]).toContain('"ok":true'); + expect(b.written).toHaveLength(1); + }); + + it("evicts a client whose write throws and continues to the rest", () => { + const dead = makeFakeClient(true); + const alive = makeFakeClient(); + const set = new Set([dead, alive]) as unknown as Set; + + broadcastSseEvent(set, "status", { x: 1 }); + + expect(set.has(dead as unknown as ServerResponse)).toBe(false); + expect(set.has(alive as unknown as ServerResponse)).toBe(true); + // Alive client still received the event despite the dead client throwing first + expect(alive.written).toHaveLength(1); + expect(dead.ended).toBe(true); // best-effort end() + }); + + it("invokes onError callback for each dead client (caller can log)", () => { + const dead = makeFakeClient(true); + const set = new Set([dead]) as unknown as Set; + const onError = vi.fn(); + + broadcastSseEvent(set, "status", {}, onError); + + expect(onError).toHaveBeenCalledTimes(1); + expect(onError.mock.calls[0][0]).toBeInstanceOf(Error); + }); +}); + +// ── /ui/events handler cleanup test ─────────────────────────────────────── + +class CaptureRes extends ServerResponse { + status = 0; + headers: Record = {}; + written: string[] = []; + errorListeners: Array<(e: unknown) => void> = []; + + constructor(req: IncomingMessage) { super(req); } + + writeHead(status: number, headers?: Record): this { + this.status = status; + if (headers) this.headers = headers; + return this; + } + + write(chunk: string): boolean { + this.written.push(chunk); + return true; + } + + // Override addListener so we can verify the handler attaches an "error" listener + on(event: string, fn: (e: unknown) => void): this { + if (event === "error") this.errorListeners.push(fn); + return super.on(event, fn); + } +} + +function makeReq(method: string, url: string): IncomingMessage & EventEmitter { + const stream = Readable.from([]) as unknown as IncomingMessage & EventEmitter; + stream.method = method; + stream.url = url; + stream.headers = {}; + return stream; +} + +function makeSseCtx(sseClients: Set): WebApiContext { + const scheduler = { + db: { + listTasks: () => [], createTask: () => ({}), updateTask: () => ({}), + claimTask: () => ({}), completeTask: () => ({}), + }, + list: () => [], create: () => ({}), delete: () => {}, + }; + return { + webToken: null, dataDir: "/tmp", sseClients, + fleetConfig: { channel: { group_id: 1 }, instances: {}, teams: {} }, + instanceIpcClients: new Map(), adapter: null, daemons: new Map(), + eventLog: null, + logger: { info() {}, debug() {}, error() {} }, + getInstanceDir: () => "/tmp", + getInstanceStatus: () => "stopped" as const, + getUiStatus: () => ({ ok: true }), + emitSseEvent: () => {}, + startInstance: async () => {}, stopInstance: async () => {}, + restartSingleInstance: async () => {}, removeInstance: async () => {}, + lastInboundUser: new Map(), + saveFleetConfig: () => {}, + lifecycle: { handleCreate: async () => {} }, + connectIpcToInstance: async () => {}, + scheduler, + } as WebApiContext; +} + +describe("/ui/events SSE handler cleanup", () => { + it("removes client + clears interval on req close", () => { + const sseClients = new Set(); + const ctx = makeSseCtx(sseClients); + const req = makeReq("GET", "/ui/events"); + const res = new CaptureRes(req); + + handleWebRequest(req, res, new URL("http://localhost/ui/events"), ctx); + + expect(sseClients.has(res)).toBe(true); + + req.emit("close"); + + expect(sseClients.has(res)).toBe(false); + }); + + it("removes client on req error (network reset, no clean FIN)", () => { + const sseClients = new Set(); + const ctx = makeSseCtx(sseClients); + const req = makeReq("GET", "/ui/events"); + const res = new CaptureRes(req); + + handleWebRequest(req, res, new URL("http://localhost/ui/events"), ctx); + expect(sseClients.has(res)).toBe(true); + + req.emit("error", new Error("ECONNRESET")); + + expect(sseClients.has(res)).toBe(false); + }); + + it("cleanup is idempotent (close + error must not both fire side effects twice)", () => { + const sseClients = new Set(); + const ctx = makeSseCtx(sseClients); + const req = makeReq("GET", "/ui/events"); + const res = new CaptureRes(req); + + handleWebRequest(req, res, new URL("http://localhost/ui/events"), ctx); + + // Fire both — second one should be a no-op, not a double-delete + clearInterval(null) + req.emit("error", new Error("x")); + expect(() => req.emit("close")).not.toThrow(); + expect(sseClients.size).toBe(0); + }); +}); diff --git a/tests/telegram-api-root.test.ts b/tests/telegram-api-root.test.ts new file mode 100644 index 00000000..f68051fb --- /dev/null +++ b/tests/telegram-api-root.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from "vitest"; +import { validateTelegramApiRoot } from "../src/channel/adapters/telegram.js"; + +describe("validateTelegramApiRoot", () => { + it("accepts the official Telegram API root", () => { + expect(() => validateTelegramApiRoot("https://api.telegram.org")).not.toThrow(); + }); + + it("accepts loopback hosts (E2E mock servers)", () => { + expect(() => validateTelegramApiRoot("http://localhost:8081")).not.toThrow(); + expect(() => validateTelegramApiRoot("http://127.0.0.1:8081")).not.toThrow(); + expect(() => validateTelegramApiRoot("http://[::1]:8081")).not.toThrow(); + }); + + it("rejects arbitrary external hosts (token-exfil risk)", () => { + expect(() => validateTelegramApiRoot("https://evil.example.com")).toThrow(/allowlist/i); + expect(() => validateTelegramApiRoot("https://api.telegram.org.evil.com")).toThrow(/allowlist/i); + }); + + it("rejects api.telegram.org over plain http (forces https)", () => { + expect(() => validateTelegramApiRoot("http://api.telegram.org")).toThrow(/https/i); + }); + + it("rejects non-http(s) schemes", () => { + expect(() => validateTelegramApiRoot("file:///etc/passwd")).toThrow(/http/i); + expect(() => validateTelegramApiRoot("ftp://api.telegram.org")).toThrow(/http/i); + }); + + it("rejects malformed URLs", () => { + expect(() => validateTelegramApiRoot("not a url")).toThrow(/Invalid/i); + expect(() => validateTelegramApiRoot("")).toThrow(/Invalid/i); + }); + + it("hostname matching is case-insensitive", () => { + expect(() => validateTelegramApiRoot("https://API.TELEGRAM.ORG")).not.toThrow(); + }); +}); diff --git a/tests/tmux-control.test.ts b/tests/tmux-control.test.ts new file mode 100644 index 00000000..f81de2d5 --- /dev/null +++ b/tests/tmux-control.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { EventEmitter } from "node:events"; + +// ── Mock node:child_process & node:readline so we never spawn real tmux ── +const spawnMock = vi.fn(); +const execFileMock = vi.fn(); + +vi.mock("node:child_process", () => ({ + spawn: (...args: unknown[]) => spawnMock(...args), + execFile: (...args: unknown[]) => execFileMock(...args), +})); + +vi.mock("node:readline", () => ({ + createInterface: vi.fn(() => ({ on: vi.fn(), close: vi.fn() })), +})); + +import { TmuxControlClient } from "../src/tmux-control.js"; + +interface FakeProc extends EventEmitter { + stdout: EventEmitter; + stdin: { write: ReturnType }; + killed: boolean; + kill: () => void; +} + +function makeFakeProc(): FakeProc { + const proc = new EventEmitter() as FakeProc; + proc.stdout = new EventEmitter(); + proc.stdin = { write: vi.fn() }; + proc.killed = false; + proc.kill = () => { proc.killed = true; }; + return proc; +} + +interface ClientInternals { + paneToWindow: Map; + lastOutputAt: Map; + registeredWindows: Set; + connect: () => void; +} + +describe("TmuxControlClient reconnect", () => { + beforeEach(() => { + spawnMock.mockReset(); + execFileMock.mockReset(); + spawnMock.mockReturnValue(makeFakeProc()); + // Default: execFile returns empty stdout so resolvePane is a harmless no-op + execFileMock.mockImplementation((_cmd, _args, cb: (e: Error | null, s: string) => void) => cb(null, "")); + }); + + it("registerWindow tracks the windowId for future reconnects", async () => { + const client = new TmuxControlClient("s", 100); + const internal = client as unknown as ClientInternals; + + execFileMock.mockImplementationOnce((_c, _a, cb) => cb(null, "%1")); + await client.registerWindow("@10"); + + expect(internal.registeredWindows.has("@10")).toBe(true); + expect(internal.paneToWindow.get("%1")).toBe("@10"); + }); + + it("unregisterWindow removes from registeredWindows AND pane caches", () => { + const client = new TmuxControlClient("s", 100); + const internal = client as unknown as ClientInternals; + internal.registeredWindows.add("@5"); + internal.paneToWindow.set("%1", "@5"); + internal.lastOutputAt.set("%1", Date.now()); + + client.unregisterWindow("@5"); + + expect(internal.registeredWindows.has("@5")).toBe(false); + expect(internal.paneToWindow.has("%1")).toBe(false); + expect(internal.lastOutputAt.has("%1")).toBe(false); + }); + + it("clears stale paneToWindow / lastOutputAt on reconnect", () => { + const client = new TmuxControlClient("s", 100); + const internal = client as unknown as ClientInternals; + // Simulate state from a prior connection + internal.paneToWindow.set("%1", "@5"); + internal.lastOutputAt.set("%1", Date.now()); + internal.registeredWindows.add("@5"); + + internal.connect(); + + expect(internal.paneToWindow.size).toBe(0); + expect(internal.lastOutputAt.size).toBe(0); + // Registered windows are preserved so resolvePane can refresh them + expect(internal.registeredWindows.has("@5")).toBe(true); + }); + + it("re-resolves all registered windows after reconnect", async () => { + const client = new TmuxControlClient("s", 100); + const internal = client as unknown as ClientInternals; + internal.registeredWindows.add("@5"); + internal.registeredWindows.add("@7"); + + const seen: string[] = []; + execFileMock.mockImplementation((_cmd, args: string[], cb: (e: Error | null, s: string) => void) => { + seen.push(args.join(" ")); + cb(null, "%99"); + }); + + internal.connect(); + // Allow the fired-and-forget resolvePane promises to settle + await new Promise(r => setImmediate(r)); + + expect(seen.some(s => s.includes("@5"))).toBe(true); + expect(seen.some(s => s.includes("@7"))).toBe(true); + }); +}); diff --git a/tests/topic-archiver.test.ts b/tests/topic-archiver.test.ts new file mode 100644 index 00000000..7dae0a59 --- /dev/null +++ b/tests/topic-archiver.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { mkdirSync, rmSync, existsSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { TopicArchiver, type ArchiverContext } from "../src/topic-archiver.js"; +import type { FleetConfig } from "../src/types.js"; + +function silentLogger() { + return { info: vi.fn(), debug: vi.fn(), warn: vi.fn(), error: vi.fn(), trace: vi.fn() }; +} + +function makeCtx(overrides: Partial = {}): ArchiverContext { + return { + fleetConfig: null, + adapter: null, + logger: silentLogger() as unknown as ArchiverContext["logger"], + dataDir: "", + getInstanceStatus: () => "running", + lastActivityMs: () => 0, + setTopicIcon: () => {}, + touchActivity: () => {}, + ...overrides, + }; +} + +describe("TopicArchiver persistence", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = join(tmpdir(), `archiver-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(tmpDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("persists archived topics across restart", async () => { + const closeForumTopic = vi.fn().mockResolvedValue(undefined); + const fleetConfig = { + instances: { + worker: { topic_id: 42, general_topic: false }, + }, + } as unknown as FleetConfig; + + // First run: archive an idle topic + const ctx1 = makeCtx({ + dataDir: tmpDir, + fleetConfig, + adapter: { closeForumTopic } as unknown as ArchiverContext["adapter"], + lastActivityMs: () => Date.now() - (TopicArchiver.IDLE_MS + 1000), + }); + const archiver1 = new TopicArchiver(ctx1); + await archiver1.archiveIdle(); + expect(archiver1.isArchived("42")).toBe(true); + expect(closeForumTopic).toHaveBeenCalledTimes(1); + + // State file should exist on disk + const statePath = join(tmpDir, "archived-topics.json"); + expect(existsSync(statePath)).toBe(true); + expect(JSON.parse(readFileSync(statePath, "utf-8"))).toEqual(["42"]); + + // Second run: fresh instance loads state, does NOT re-archive + const ctx2 = makeCtx({ + dataDir: tmpDir, + fleetConfig, + adapter: { closeForumTopic } as unknown as ArchiverContext["adapter"], + lastActivityMs: () => Date.now() - (TopicArchiver.IDLE_MS + 1000), + }); + const archiver2 = new TopicArchiver(ctx2); + expect(archiver2.isArchived("42")).toBe(true); // remembered + await archiver2.archiveIdle(); + expect(closeForumTopic).toHaveBeenCalledTimes(1); // not called again + }); + + it("removes id from state on reopen", async () => { + const reopenForumTopic = vi.fn().mockResolvedValue(undefined); + const ctx = makeCtx({ + dataDir: tmpDir, + adapter: { reopenForumTopic } as unknown as ArchiverContext["adapter"], + }); + const archiver = new TopicArchiver(ctx); + // Seed state directly via archiveIdle path + (archiver as unknown as { archived: Set }).archived.add("99"); + (archiver as unknown as { save: () => void }).save(); + + await archiver.reopen("99", "worker"); + expect(archiver.isArchived("99")).toBe(false); + + // Re-load fresh instance — should NOT have 99 + const archiver2 = new TopicArchiver(ctx); + expect(archiver2.isArchived("99")).toBe(false); + }); + + it("tolerates missing state file on first start", () => { + const ctx = makeCtx({ dataDir: tmpDir }); + expect(() => new TopicArchiver(ctx)).not.toThrow(); + }); + + it("tolerates corrupted state file", () => { + const statePath = join(tmpDir, "archived-topics.json"); + writeFileSync(statePath, "not valid json{{"); + const ctx = makeCtx({ dataDir: tmpDir }); + const archiver = new TopicArchiver(ctx); + expect(archiver.isArchived("anything")).toBe(false); + }); +}); diff --git a/tests/transcript-monitor.test.ts b/tests/transcript-monitor.test.ts index cf2675e2..23872f6b 100644 --- a/tests/transcript-monitor.test.ts +++ b/tests/transcript-monitor.test.ts @@ -81,4 +81,19 @@ describe("TranscriptMonitor", () => { await monitor.pollIncrement(); // no new data expect(texts).toHaveLength(1); }); + + it("skips concurrent pollIncrement calls (reentry guard)", async () => { + const jsonlPath = join(tmpDir, "transcript.jsonl"); + const entry = { message: { role: "assistant", content: [{ type: "text", text: "once" }] } }; + writeFileSync(jsonlPath, JSON.stringify(entry) + "\n"); + + monitor.setTranscriptPath(jsonlPath); + const texts: string[] = []; + monitor.on("assistant_text", (text) => texts.push(text)); + + // Fire two polls simultaneously; second should bail before reading + // any bytes, so the entry is emitted exactly once. + await Promise.all([monitor.pollIncrement(), monitor.pollIncrement()]); + expect(texts).toHaveLength(1); + }); }); diff --git a/tests/web-api.test.ts b/tests/web-api.test.ts new file mode 100644 index 00000000..b98fc694 --- /dev/null +++ b/tests/web-api.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { IncomingMessage, ServerResponse } from "node:http"; +import { Readable } from "node:stream"; +import { handleWebRequest, type WebApiContext } from "../src/web-api.js"; + +// Minimal mock ServerResponse that captures status + body. +class CaptureRes extends ServerResponse { + status = 0; + body = ""; + constructor(req: IncomingMessage) { + super(req); + } + writeHead(status: number): this { + this.status = status; + return this; + } + end(chunk?: unknown): this { + if (chunk) this.body += String(chunk); + return this; + } +} + +function makeReq(method: string, url: string, body?: unknown): IncomingMessage { + const raw = body ? JSON.stringify(body) : ""; + const stream = Readable.from(raw ? [raw] : []); + // Cast via IncomingMessage — tests only rely on method/url/body stream. + const req = stream as unknown as IncomingMessage; + req.method = method; + req.url = url; + req.headers = {}; + return req; +} + +function makeCtx(overrides: Partial = {}): WebApiContext { + const scheduler = { + db: { + listTasks: () => [], + createTask: (p: { title: string }) => ({ id: "t1", ...p }), + updateTask: (id: string, p: Record) => ({ id, ...p }), + claimTask: (id: string, a: string) => ({ id, assignee: a }), + completeTask: (id: string, r?: string) => ({ id, result: r }), + }, + list: () => [], + create: (p: unknown) => ({ id: "s1", ...(p as object) }), + delete: () => {}, + }; + return { + webToken: null, + dataDir: "/tmp", + sseClients: new Set(), + fleetConfig: { channel: { group_id: 1 }, instances: {}, teams: {} }, + instanceIpcClients: new Map(), + adapter: null, + daemons: new Map(), + eventLog: null, + logger: { info() {}, debug() {}, error() {} }, + getInstanceDir: () => "/tmp", + getInstanceStatus: () => "stopped" as const, + getUiStatus: () => ({}), + emitSseEvent: () => {}, + startInstance: async () => {}, + stopInstance: async () => {}, + restartSingleInstance: async () => {}, + removeInstance: async () => {}, + lastInboundUser: new Map(), + saveFleetConfig: () => {}, + lifecycle: { handleCreate: async () => {} }, + connectIpcToInstance: async () => {}, + scheduler, + ...overrides, + } as WebApiContext; +} + +async function callAndWait( + method: string, + url: string, + body: unknown, + ctx: WebApiContext, +): Promise { + const req = makeReq(method, url, body); + const res = new CaptureRes(req); + const urlObj = new URL(url, "http://localhost"); + // Append token query so auth passes (test sets webToken=null, and handler + // compares with `!==`, so we need to leave token unset AND webToken null). + handleWebRequest(req, res, urlObj, ctx); + // Let the async handler resolve (parseBody reads from the stream). + for (let i = 0; i < 20; i++) { + if (res.status !== 0) break; + await new Promise((r) => setTimeout(r, 5)); + } + return res; +} + +describe("web-api zod validation", () => { + let ctx: WebApiContext; + beforeEach(() => { ctx = makeCtx(); }); + + it("POST /ui/tasks rejects missing title", async () => { + const res = await callAndWait("POST", "/ui/tasks", { description: "x" }, ctx); + expect(res.status).toBe(400); + expect(res.body).toMatch(/title/); + }); + + it("POST /ui/tasks rejects unknown field", async () => { + const res = await callAndWait( + "POST", + "/ui/tasks", + { title: "ok", admin: true }, + ctx, + ); + expect(res.status).toBe(400); + }); + + it("POST /ui/tasks accepts valid payload", async () => { + const res = await callAndWait("POST", "/ui/tasks", { title: "ok" }, ctx); + expect(res.status).toBe(200); + }); + + it("POST /ui/schedules rejects payload missing cron", async () => { + const res = await callAndWait( + "POST", + "/ui/schedules", + { message: "m", target: "t" }, + ctx, + ); + expect(res.status).toBe(400); + }); + + it("POST /ui/teams rejects invalid name", async () => { + const res = await callAndWait( + "POST", + "/ui/teams", + { name: "a b;rm -rf /", members: ["u1"] }, + ctx, + ); + expect(res.status).toBe(400); + }); + + it("POST /ui/config rejects unknown top-level field", async () => { + const res = await callAndWait( + "POST", + "/ui/config", + { hacked: true }, + ctx, + ); + expect(res.status).toBe(400); + }); +}); diff --git a/website/public/install.sh b/website/public/install.sh index fd921595..54f0670e 100755 --- a/website/public/install.sh +++ b/website/public/install.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # AgEnD Bootstrap Installer -# Usage: curl -fsSL https://suzuke.github.io/AgEnD/install.sh | bash +# Usage: curl -fsSL https://songsid.github.io/AgEnD/install.sh | bash # # This script: # 1. Detects OS + architecture @@ -10,12 +10,19 @@ # 5. Detects backend CLIs # 6. Runs agend quickstart # -# Source: https://github.com/suzuke/AgEnD/blob/main/website/public/install.sh +# Source: https://github.com/songsid/AgEnD/blob/main/website/public/install.sh set -euo pipefail # ── Helpers ─────────────────────────────────────────────── +# Default SUDO — empty for root, "sudo" for regular users +if [ "$(id -u)" -eq 0 ]; then + SUDO="" +else + SUDO="sudo" +fi + GREEN='\033[0;32m' YELLOW='\033[0;33m' RED='\033[0;31m' @@ -127,6 +134,29 @@ if [ "$NODE_OK" = false ]; then error "Failed to install Node.js. Please install manually: https://nodejs.org" fi info "Node.js $(node -v) installed via nvm" + + # Ensure nvm loads in login shells (some .bashrc files exit early for non-interactive shells) + ensure_nvm_in_profile() { + local profile_file="" + if [ -f "$HOME/.bash_profile" ]; then + profile_file="$HOME/.bash_profile" + elif [ -f "$HOME/.profile" ]; then + profile_file="$HOME/.profile" + else + profile_file="$HOME/.profile" + fi + + if ! grep -q "NVM_DIR" "$profile_file" 2>/dev/null; then + cat >> "$profile_file" << 'EOF' + +# NVM (added by AgEnD installer) +export NVM_DIR="$HOME/.nvm" +[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" +EOF + info "Added nvm to $profile_file for new shell sessions" + fi + } + ensure_nvm_in_profile fi # Ensure nvm node is first in PATH on WSL @@ -165,17 +195,66 @@ fi step 4 "Installing AgEnD" +# Ensure build tools are available (needed for native modules like better-sqlite3) +if ! command_exists g++; then + echo " Installing build tools (needed for native modules)..." + case "$PKG_MGR" in + brew) ;; # macOS Xcode CLT usually provides this + apt) $SUDO apt-get update -qq && $SUDO apt-get install -y -qq build-essential python3 ;; + dnf) $SUDO dnf groupinstall -y "Development Tools" ;; + pacman) $SUDO pacman -S --noconfirm base-devel ;; + esac +fi + if command_exists agend; then CURRENT=$(agend --version 2>/dev/null || echo "unknown") warn "AgEnD already installed (${CURRENT}), upgrading..." fi -npm install -g @suzuke/agend +# Detect if npm global dir needs sudo +NPM_PREFIX=$(npm config get prefix 2>/dev/null || echo "/usr/local") +if [ -w "$NPM_PREFIX/lib/node_modules" ] 2>/dev/null || [ -w "$NPM_PREFIX/lib" ] 2>/dev/null; then + SUDO="" +else + SUDO="sudo" +fi + +# Remove old @suzuke/agend if present (bin name conflict) +if npm list -g @suzuke/agend >/dev/null 2>&1; then + warn "Removing old @suzuke/agend to avoid conflicts..." + $SUDO npm uninstall -g @suzuke/agend 2>/dev/null || true +elif command_exists agend; then + AGEND_REAL=$(readlink -f "$(command -v agend)" 2>/dev/null || true) + if [[ "$AGEND_REAL" == */AgEnD/* ]] || [[ "$AGEND_REAL" == *"@suzuke/agend"* ]] || [[ "$AGEND_REAL" == */Projects/* ]]; then + warn "Removing npm-linked @suzuke/agend..." + AGEND_BIN_DIR=$(dirname "$(command -v agend)") + $SUDO rm -f "$AGEND_BIN_DIR/agend" "$AGEND_BIN_DIR/agend-agent" 2>/dev/null || true + NVM_MODULES="$(dirname "$AGEND_BIN_DIR")/lib/node_modules/@suzuke" + $SUDO rm -rf "$NVM_MODULES" 2>/dev/null || true + fi +fi + +$SUDO npm install -g @songsid/agend @songsid/agend-plugin-discord if ! command_exists agend; then - error "Installation failed. Try: npm install -g @suzuke/agend" + error "Installation failed. Try: npm install -g @songsid/agend" fi info "AgEnD $(agend --version) installed" +info "Discord plugin $(npm list -g @songsid/agend-plugin-discord --depth=0 2>/dev/null | grep agend-plugin-discord | sed 's/.*@//' || echo 'unknown') installed" + +# ── Ensure binaries are accessible without nvm sourced ──── +# Root user: create symlinks in /usr/local/bin (for systemd, cron, non-login shells) +# Normal user: nvm PATH in .bashrc/.profile is sufficient +AGEND_BIN=$(command -v agend 2>/dev/null) +NODE_BIN=$(command -v node 2>/dev/null) +if [ -n "$AGEND_BIN" ] && [[ "$AGEND_BIN" == */.nvm/* ]] && [ "$(id -u)" -eq 0 ]; then + ln -sf "$AGEND_BIN" /usr/local/bin/agend 2>/dev/null && info "Symlinked agend → /usr/local/bin/agend" || true + ln -sf "$NODE_BIN" /usr/local/bin/node 2>/dev/null && info "Symlinked node → /usr/local/bin/node" || true + KIRO_BIN=$(command -v kiro-cli 2>/dev/null) + if [ -n "$KIRO_BIN" ]; then + ln -sf "$KIRO_BIN" /usr/local/bin/kiro-cli 2>/dev/null && info "Symlinked kiro-cli → /usr/local/bin/kiro-cli" || true + fi +fi # ── Step 5: Detect backend ─────────────────────────────── @@ -206,6 +285,9 @@ echo -e "\n${BOLD}═══ Installation Complete ═══${NC}\n" echo " Run the setup wizard:" echo -e " ${BOLD}agend quickstart${NC}\n" +warn "If 'agend' is not found in a new terminal, run: source ~/.profile" +echo "" + # Auto-launch if interactive terminal if [ -t 0 ] && [ -t 1 ]; then read -rp " Launch quickstart now? [Y/n] " answer diff --git a/website/src/i18n/en.json b/website/src/i18n/en.json index 4b88e1c3..908991de 100644 --- a/website/src/i18n/en.json +++ b/website/src/i18n/en.json @@ -67,10 +67,10 @@ "title": "Up and running in minutes", "oneLiner": { "label": "One-liner (macOS / Linux)", - "cmd": "curl -fsSL https://suzuke.github.io/AgEnD/install.sh | bash", + "cmd": "curl -fsSL https://songsid.github.io/AgEnD/install.sh | bash", "note": "Installs Node.js (via nvm), tmux, agend, then launches the wizard. Or step through manually below." }, - "step1": { "label": "Install", "cmd": "npm install -g @suzuke/agend" }, + "step1": { "label": "Install", "cmd": "npm install -g @songsid/agend" }, "step2": { "label": "Setup", "cmd": "agend quickstart" }, "step3": { "label": "Launch", "cmd": "agend fleet start" }, "footer": "Open Telegram, send a message, start coding from your phone." diff --git a/website/src/i18n/zh-tw.json b/website/src/i18n/zh-tw.json index 5a841464..cf844699 100644 --- a/website/src/i18n/zh-tw.json +++ b/website/src/i18n/zh-tw.json @@ -67,10 +67,10 @@ "title": "幾分鐘就能上線", "oneLiner": { "label": "一行安裝(macOS / Linux)", - "cmd": "curl -fsSL https://suzuke.github.io/AgEnD/install.sh | bash", + "cmd": "curl -fsSL https://songsid.github.io/AgEnD/install.sh | bash", "note": "會透過 nvm 裝 Node.js,再裝 tmux、agend,接著直接進設定精靈。或照下面三步手動設定。" }, - "step1": { "label": "安裝", "cmd": "npm install -g @suzuke/agend" }, + "step1": { "label": "安裝", "cmd": "npm install -g @songsid/agend" }, "step2": { "label": "設定", "cmd": "agend quickstart" }, "step3": { "label": "啟動", "cmd": "agend fleet start" }, "footer": "打開 Telegram,傳訊息,開始用手機寫 code。"