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..738517dd
--- /dev/null
+++ b/.github/workflows/publish.yml
@@ -0,0 +1,64 @@
+name: Publish
+
+on:
+ push:
+ tags: ['v*']
+
+jobs:
+ publish:
+ runs-on: ubuntu-latest
+ 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.
-
+
@@ -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 團隊。
-
+
@@ -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..b93705cd 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -4,7 +4,141 @@ 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]
+## [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/configuration.md b/docs/configuration.md
index 9cd29c88..e1143ef4 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -104,7 +104,7 @@ health_port: 19280
| Field | Type | Default | Description |
|-------|------|---------|-------------|
-| `mode` | `"locked"` \| `"pairing"` | `"locked"` | `locked` = whitelist only. `pairing` = users can request access via `/pair` command (requires manual code confirmation) |
+| `mode` | `"locked"` \| `"pairing"` \| `"open"` | `"locked"` | `locked` = whitelist only. `pairing` = users can request access via `/pair` command. `open` = all users allowed without an allowlist |
| `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 |
@@ -222,7 +222,7 @@ Use `broadcast(team: "backend-squad", message: "...")` to send to all members.
| `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` |
+| `backend` | string | `"claude-code"` | CLI backend: `claude-code`, `codex`, `gemini-cli`, `opencode`, `kiro-cli`, `antigravity` |
| `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) |
@@ -414,6 +414,57 @@ Values in `~/.agend/.env` take priority over inherited shell environment variabl
---
+## classicBot.yaml
+
+ClassicBot mode uses a separate config file at `~/.agend/classicBot.yaml`. This file is auto-created when you first use `/start` in a Discord text channel, and can be manually edited.
+
+```yaml
+# ClassicBot Configuration
+# Available backends: claude-code, gemini-cli, codex, opencode, kiro-cli, antigravity
+defaults:
+ backend: claude-code # Default backend for all classic channels
+
+channels:
+ general-chat: # Channel key (used to derive instance name: classic-general-chat)
+ channelId: "1234567890" # Discord channel ID
+ backend: gemini-cli # Optional: override default backend for this channel
+ createdBy: "368442276000694273"
+ createdAt: "2026-04-12T02:00:00Z"
+ dev-help:
+ channelId: "9876543210" # No backend override → uses defaults.backend
+ createdBy: "368442276000694273"
+ createdAt: "2026-04-12T02:10:00Z"
+```
+
+### Key behaviors
+
+- **Backend fallback chain**: per-channel `backend` → `defaults.backend` → `fleet.yaml` `defaults.backend` → `claude-code`
+- **Auto-update**: `/start` adds a channel entry, `/stop` removes it
+- **Hot reload**: file changes are detected every 30 seconds — edit `defaults.backend` or a channel's `backend` and it takes effect on the next `/chat` message
+- **Instance naming**: derived from the channel key — `classic-`
+- **`agend ls`**: classic instances appear with a `(classic)` tag
+
+### Additional fields
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `defaults.model` | string | Default model override for all classic channels |
+| `defaults.context_lines` | number | Lines of chat history injected before each message (default: 50, set 0 to disable) |
+| `defaults.allowed_guilds` | string[] | Discord server IDs allowed to use ClassicBot (empty = all allowed) |
+| `defaults.allowed_groups` | string[] | Telegram group IDs allowed to use ClassicBot |
+| `defaults.allowed_users` | string[] | User IDs allowed to interact |
+| `defaults.admin_users` | string[] | User IDs with admin access (/compact, /save, /load) |
+| `channels..model` | string | Per-channel model override |
+| `channels..context_lines` | number | Per-channel chat history lines |
+
+### Manual management
+
+You can manually edit `classicBot.yaml` to:
+- Change the default backend for all classic channels
+- Override the backend for a specific channel
+- Remove channels (equivalent to `/stop`)
+- Add channels (they'll be picked up on next reload, but you'll also need to restart the fleet to start the instance)
+
## Data directory
`~/.agend/`:
@@ -421,6 +472,7 @@ Values in `~/.agend/.env` take priority over inherited shell environment variabl
| Path | Purpose |
|------|---------|
| `fleet.yaml` | Fleet configuration |
+| `classicBot.yaml` | ClassicBot channel config (per-channel backend) |
| `.env` | Bot token + API keys |
| `daemon.log` | Fleet daemon log |
| `fleet.pid` | Fleet manager PID |
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..c1590c3e 100644
--- a/src/channel/adapters/telegram.ts
+++ b/src/channel/adapters/telegram.ts
@@ -28,6 +28,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 +83,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 +138,13 @@ 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.)
@@ -134,6 +178,7 @@ export class TelegramAdapter extends EventEmitter implements ChannelAdapter {
? String(msg.reply_to_message.message_id)
: undefined,
replyToText: replyToText || undefined,
+ chatTitle: msg.chat.title || undefined,
});
});
@@ -261,28 +306,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 +375,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 +616,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 +747,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..036d4067 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);
@@ -947,7 +1155,7 @@ program
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("Or start manually: agend start");
return;
}
if (startService()) {
@@ -1284,6 +1492,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 +1520,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")) {
@@ -1354,7 +1588,7 @@ function formatTimeSince(isoStr: string): string {
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+)%/);
+ const m = output.match(/(\d+)%.*!>/m) || output.match(/◔\s*(\d+)%/);
return m ? parseInt(m[1], 10) : null;
},
};
@@ -1364,7 +1598,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 +1642,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 +1694,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 +1722,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 +1734,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 +1758,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 +1772,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 +1784,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 +1966,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 +1998,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..44cb21b5 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,32 @@ export class FleetManager implements FleetContext, LifecycleContext, ArchiverCon
});
this.dailySummary.start();
- // Auto-create general instance if none configured
+ // Rotate classic channel chat logs daily (piggyback on daily summary timer)
+ this.classicChannels?.rotateLogs();
+
+ // Auto-create general instance(s) — one per adapter when multi-channel
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");
- mkdirSync(generalDir, { recursive: true });
- const backendName = fleet.defaults.backend ?? "claude-code";
- this.ensureGeneralInstructions(generalDir, backendName);
- const generalConfig: InstanceConfig = {
- ...DEFAULT_INSTANCE_CONFIG,
- working_directory: generalDir,
- general_topic: true,
- };
- fleet.instances["general"] = generalConfig;
+ const channelConfigs = fleet.channels ?? (fleet.channel ? [fleet.channel] : []);
+ const needsSuffix = channelConfigs.length > 1;
+ for (const ch of channelConfigs) {
+ const name = needsSuffix ? `general-${ch.id ?? ch.type}` : "general";
+ if (fleet.instances[name]) continue;
+ this.logger.info({ name }, "Auto-creating general instance");
+ const generalDir = join(getAgendHome(), name);
+ mkdirSync(generalDir, { recursive: true });
+ const backendName = fleet.defaults.backend ?? "claude-code";
+ this.ensureGeneralInstructions(generalDir, backendName);
+ fleet.instances[name] = {
+ ...DEFAULT_INSTANCE_CONFIG,
+ working_directory: generalDir,
+ general_topic: true,
+ };
+ }
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 +520,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 +540,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 (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);
+
+ // Phase 2: Start remaining instances with staggered concurrency
+ if (others.length > 0) {
+ await this.startInstancesWithConcurrency(others, topicMode);
+ }
+
+ if (topicMode && (fleet.channel || fleet.channels?.length)) {
- if (topicMode && fleet.channel) {
+ try {
+ await this.startSharedAdapter(fleet);
+ } catch (err) {
+ this.logger.error({ err }, "startSharedAdapter failed — fleet continues without some adapters");
+ }
- await this.startSharedAdapter(fleet);
+ // 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 +644,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 +683,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 +711,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 +757,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+)%/);
+ 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 +903,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 +929,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 +1127,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 +1187,258 @@ 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) {
+ const match = generals.find(n => n.includes(adapterId));
+ if (match) return match;
+ }
+ return generals[0];
}
private async handleInboundMessage(msg: InboundMessage): Promise {
const threadId = msg.threadId || undefined;
+
+ // Bot messages: only allow in collab channels
+ if (msg.isBotMessage) {
+ if (!threadId) return;
+ 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: "" };
+ 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 +1469,55 @@ export class FleetManager implements FleetContext, LifecycleContext, ArchiverCon
const target = this.routing.resolve(threadId);
if (!target) {
- this.topicCommands.handleUnboundTopic(msg);
+ // Suppress unbound topic message for Discord — regular text channels are expected
+ // to be unbound (classic mode or user-created). Only show for Telegram forum topics.
+ if (msg.source !== "discord") {
+ 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 +1525,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 +1566,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 +1600,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)}`);
@@ -839,7 +1636,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 +1649,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 +1719,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 +2095,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 +2114,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 +2143,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) {
@@ -1423,6 +2230,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,11 +2289,13 @@ export class FleetManager implements FleetContext, LifecycleContext, ArchiverCon
}
notifyInstanceTopic(instanceName: string, text: string): void {
- 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.adapter.sendText(String(groupId), text, {
+ 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"));
}
@@ -1502,10 +2322,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 +2334,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 +2458,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 +2500,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 +2522,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 +2601,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 +2624,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 +2954,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 +3130,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 +3143,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 +3252,41 @@ Design Proposed → Design Approved → Implementation → Submit for Review →
const fleet = this.loadConfig(this.configPath);
this.fleetConfig = fleet;
- const topicMode = fleet.channel?.mode === "topic";
+ const topicMode = fleet.channel?.mode === "topic" || !!fleet.channels?.some(ch => ch.mode === "topic");
- await this.startInstancesWithConcurrency(Object.entries(fleet.instances), topicMode);
+ // 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 +3303,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"));
- }
+ 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"));
+ }
- // 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(),
- },
- });
+ 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 +3373,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 +3387,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/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..e88050fe
--- /dev/null
+++ b/src/general-knowledge/steering/core-rules.md
@@ -0,0 +1,56 @@
+# 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
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..d486cc2d 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,11 @@ 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);
+ 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 +763,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..a6efe754 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,223 @@ 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(` To get a Channel ID for the General channel:`);
+ console.log(` Right-click the text channel → ${bold("Copy Channel ID")}\n`);
+ const generalChannelId = (await rl.question(" Paste General Channel ID (optional, Enter to skip): ")).trim();
+ if (generalChannelId) {
+ console.log(` ${green("✓")} General Channel: ${generalChannelId}\n`);
+ } else {
+ console.log(` ${dim("Skipped")}\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 {
@@ -135,13 +353,100 @@ export async function runQuickstart(): Promise {
// 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}`);
+
+ 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);
+ console.log(`\n${bold("═══ Done ═══")}\n`);
return;
}
+
+ if (action !== "3") {
+ // Skip (default)
+ await maybeUpdateClassicBot(rl);
+ console.log(`\n${bold("═══ Done ═══")}\n`);
+ return;
+ }
+ // action === "3" → fall through to full setup
}
// Check tmux
@@ -181,100 +486,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`);
-
- } 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}`);
- }
+ const channelChoice = chChoice.trim() === "3" ? "both" : chChoice.trim() === "2" ? "discord" : "telegram";
+ console.log(` ${green("✓")} ${channelChoice}\n`);
- 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 +540,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..75c98458 100644
--- a/src/topic-commands.ts
+++ b/src/topic-commands.ts
@@ -1,12 +1,12 @@
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";
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 +20,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 +49,67 @@ 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+)%/);
+ 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)
+ 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);
@@ -75,6 +123,7 @@ export class TopicCommands {
} catch { /* file may not exist yet */ }
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 +131,150 @@ 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 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 |",
+ "|--------|-------|",
+ `| 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 +311,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 +330,20 @@ 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: use Discord general_channel_id if available, else Telegram thread_id = 1
if (config.general_topic) {
- config.topic_id = 1;
+ const ch = this.ctx.fleetConfig?.channel;
+ if (name.includes("telegram")) {
+ config.topic_id = 1;
+ } else if (name.includes("discord") && ch?.options?.general_channel_id) {
+ config.topic_id = ch.options.general_channel_id as string | number;
+ } else {
+ // Default: use discord general_channel_id if available, else Telegram thread_id 1
+ const discordGeneralId = ch?.options?.general_channel_id as string | undefined;
+ config.topic_id = discordGeneralId || 1;
+ }
configChanged = true;
- this.ctx.logger.info({ name, topicId: 1 }, "Bound to native General topic");
+ this.ctx.logger.info({ name, topicId: config.topic_id }, "Bound to General topic");
continue;
}
@@ -277,32 +365,56 @@ 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" },
+ { command: "chat", description: "[ClassicBot] Talk to the agent" },
+ ],
+ scope: { type: "default" },
+ }),
+ },
+ );
+
+ this.ctx.logger.info({ adapterId: ch.id ?? ch.type }, "Registered bot commands: /status (forum), /start /stop /chat (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