merge main
This commit is contained in:
commit
88d754b172
29 changed files with 507 additions and 2892 deletions
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -60,4 +60,9 @@ cmd/telegram/
|
|||
web/backend/dist/*
|
||||
!web/backend/dist/.gitkeep
|
||||
|
||||
.claude/
|
||||
<<<<<<< HEAD
|
||||
.claude/
|
||||
=======
|
||||
|
||||
docker/data
|
||||
>>>>>>> upstream-main
|
||||
|
|
|
|||
796
README.md
796
README.md
|
|
@ -747,797 +747,5 @@ User Groups:
|
|||
|
||||
discord: <https://discord.gg/V4sAZ9XWpN>
|
||||
|
||||
<img src="assets/wechat.png" alt="PicoClaw" width="512">
center">
|
||||
<img src="assets/logo.webp" alt="PicoClaw" width="512">
|
||||
|
||||
<h1>PicoClaw: Ultra-Efficient AI Assistant in Go</h1>
|
||||
|
||||
<h3>$10 Hardware · <10MB RAM · <1s Boot · 皮皮虾,我们走!</h3>
|
||||
<p>
|
||||
<img src="https://img.shields.io/badge/Go-1.25+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
|
||||
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20MIPS%2C%20RISC--V%2C%20LoongArch-blue" alt="Hardware">
|
||||
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
||||
<br>
|
||||
<a href="https://picoclaw.io"><img src="https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white" alt="Website"></a>
|
||||
<a href="https://docs.picoclaw.io/"><img src="https://img.shields.io/badge/Docs-Official-007acc?style=flat&logo=read-the-docs&logoColor=white" alt="Docs"></a>
|
||||
<a href="https://deepwiki.com/sipeed/picoclaw"><img src="https://img.shields.io/badge/Wiki-DeepWiki-FFA500?style=flat&logo=wikipedia&logoColor=white" alt="Wiki"></a>
|
||||
<br>
|
||||
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
|
||||
<a href="./assets/wechat.png"><img src="https://img.shields.io/badge/WeChat-Group-41d56b?style=flat&logo=wechat&logoColor=white"></a>
|
||||
<a href="https://discord.gg/V4sAZ9XWpN"><img src="https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
|
||||
</p>
|
||||
|
||||
[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | **English**
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
> **PicoClaw** is an independent open-source project initiated by [Sipeed](https://sipeed.com). It is written entirely in **Go** — not a fork of OpenClaw, NanoBot, or any other project.
|
||||
|
||||
🦐 PicoClaw is an ultra-lightweight personal AI Assistant inspired by [NanoBot](https://github.com/HKUDS/nanobot), refactored from the ground up in Go through a self-bootstrapping process, where the AI agent itself drove the entire architectural migration and code optimization.
|
||||
|
||||
⚡️ Runs on $10 hardware with <10MB RAM: That's 99% less memory than OpenClaw and 98% cheaper than a Mac mini!
|
||||
|
||||
<table align="center">
|
||||
<tr align="center">
|
||||
<td align="center" valign="top">
|
||||
<p align="center">
|
||||
<img src="assets/picoclaw_mem.gif" width="360" height="240">
|
||||
</p>
|
||||
</td>
|
||||
<td align="center" valign="top">
|
||||
<p align="center">
|
||||
<img src="assets/licheervnano.png" width="400" height="240">
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
> [!CAUTION]
|
||||
> **🚨 SECURITY & OFFICIAL CHANNELS / 安全声明**
|
||||
>
|
||||
> * **NO CRYPTO:** PicoClaw has **NO** official token/coin. All claims on `pump.fun` or other trading platforms are **SCAMS**.
|
||||
>
|
||||
> * **OFFICIAL DOMAIN:** The **ONLY** official website is **[picoclaw.io](https://picoclaw.io)**, and company website is **[sipeed.com](https://sipeed.com)**
|
||||
> * **Warning:** Many `.ai/.org/.com/.net/...` domains are registered by third parties.
|
||||
> * **Warning:** picoclaw is in early development now and may have unresolved network security issues. Do not deploy to production environments before the v1.0 release.
|
||||
> * **Note:** picoclaw has recently merged a lot of PRs, which may result in a larger memory footprint (10–20MB) in the latest versions. We plan to prioritize resource optimization as soon as the current feature set reaches a stable state.
|
||||
|
||||
## 📢 News
|
||||
|
||||
2026-03-17 🚀 **v0.2.3 Released!** System tray UI (Windows & Linux), sub-agent status tracking (`spawn_status`), experimental gateway hot-reload, cron security gates, and 2 security fixes. PicoClaw now at **25K ⭐**!
|
||||
|
||||
2026-03-09 🎉 **v0.2.1 — Biggest update yet!** MCP protocol support, 4 new channels (Matrix/IRC/WeCom/Discord Proxy), 3 new providers (Kimi/Minimax/Avian), vision pipeline, JSONL memory store, and model routing.
|
||||
|
||||
2026-02-28 📦 **v0.2.0** released with Docker Compose support and Web UI launcher.
|
||||
|
||||
2026-02-26 🎉 PicoClaw hit **20K stars** in just 17 days! Channel auto-orchestration and capability interfaces landed.
|
||||
|
||||
<details>
|
||||
<summary>Older news...</summary>
|
||||
|
||||
2026-02-16 🎉 PicoClaw hit 12K stars in one week! Community maintainer roles and [roadmap](ROADMAP.md) officially posted.
|
||||
|
||||
2026-02-13 🎉 PicoClaw hit 5000 stars in 4 days! Project Roadmap and Developer Group setup underway.
|
||||
|
||||
2026-02-09 🎉 **PicoClaw Launched!** Built in 1 day to bring AI Agents to $10 hardware with <10MB RAM. 🦐 PicoClaw,Let's Go!
|
||||
|
||||
</details>
|
||||
|
||||
## ✨ Features
|
||||
|
||||
🪶 **Ultra-Lightweight**: <10MB Memory footprint — 99% smaller than OpenClaw core functionality.*
|
||||
|
||||
💰 **Minimal Cost**: Efficient enough to run on $10 Hardware — 98% cheaper than a Mac mini.
|
||||
|
||||
⚡️ **Lightning Fast**: 400X Faster startup time, boot in <1 second even on 0.6GHz single core.
|
||||
|
||||
🌍 **True Portability**: Single self-contained binary across RISC-V, ARM, MIPS, and x86, One-click to Go!
|
||||
|
||||
🤖 **AI-Bootstrapped**: Autonomous Go-native implementation — 95% Agent-generated core with human-in-the-loop refinement.
|
||||
|
||||
🔌 **MCP Support**: Native [Model Context Protocol](https://modelcontextprotocol.io/) integration — connect any MCP server to extend agent capabilities.
|
||||
|
||||
👁️ **Vision Pipeline**: Send images and files directly to the agent — automatic base64 encoding for multimodal LLMs.
|
||||
|
||||
🧠 **Smart Routing**: Rule-based model routing — simple queries go to lightweight models, saving API costs.
|
||||
|
||||
_*Recent versions may use 10–20MB due to rapid feature merges. Resource optimization is planned. Startup comparison based on 0.8GHz single-core benchmarks (see table below)._
|
||||
|
||||
| | OpenClaw | NanoBot | **PicoClaw** |
|
||||
| ----------------------------- | ------------- | ------------------------ | ----------------------------------------- |
|
||||
| **Language** | TypeScript | Python | **Go** |
|
||||
| **RAM** | >1GB | >100MB | **< 10MB*** |
|
||||
| **Startup**</br>(0.8GHz core) | >500s | >30s | **<1s** |
|
||||
| **Cost** | Mac Mini $599 | Most Linux SBC </br>~$50 | **Any Linux Board**</br>**As low as $10** |
|
||||
|
||||
<img src="assets/compare.jpg" alt="PicoClaw" width="512">
|
||||
|
||||
> 📋 **[Hardware Compatibility List](docs/hardware-compatibility.md)** — See all tested boards, from $5 RISC-V to Raspberry Pi to Android phones. Your board not listed? Submit a PR!
|
||||
|
||||
## 🦾 Demonstration
|
||||
|
||||
### 🛠️ Standard Assistant Workflows
|
||||
|
||||
<table align="center">
|
||||
<tr align="center">
|
||||
<th><p align="center">🧩 Full-Stack Engineer</p></th>
|
||||
<th><p align="center">🗂️ Logging & Planning Management</p></th>
|
||||
<th><p align="center">🔎 Web Search & Learning</p></th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><p align="center"><img src="assets/picoclaw_code.gif" width="240" height="180"></p></td>
|
||||
<td align="center"><p align="center"><img src="assets/picoclaw_memory.gif" width="240" height="180"></p></td>
|
||||
<td align="center"><p align="center"><img src="assets/picoclaw_search.gif" width="240" height="180"></p></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">Develop • Deploy • Scale</td>
|
||||
<td align="center">Schedule • Automate • Memory</td>
|
||||
<td align="center">Discovery • Insights • Trends</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
### 📱 Run on old Android Phones
|
||||
|
||||
Give your decade-old phone a second life! Turn it into a smart AI Assistant with PicoClaw. Quick Start:
|
||||
|
||||
1. **Install [Termux](https://github.com/termux/termux-app)** (Download from [GitHub Releases](https://github.com/termux/termux-app/releases), or search in F-Droid / Google Play).
|
||||
2. **Execute cmds**
|
||||
|
||||
```bash
|
||||
# Download the latest release from https://github.com/sipeed/picoclaw/releases
|
||||
wget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz
|
||||
tar xzf picoclaw_Linux_arm64.tar.gz
|
||||
pkg install proot
|
||||
termux-chroot ./picoclaw onboard
|
||||
```
|
||||
|
||||
And then follow the instructions in the "Quick Start" section to complete the configuration!
|
||||
|
||||
<img src="assets/termux.jpg" alt="PicoClaw" width="512">
|
||||
|
||||
### 🐜 Innovative Low-Footprint Deploy
|
||||
|
||||
PicoClaw can be deployed on almost any Linux device!
|
||||
|
||||
- $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) E(Ethernet) or W(WiFi6) version, for Minimal Home Assistant
|
||||
- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), or $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html) for Automated Server Maintenance
|
||||
- $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) or $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera) for Smart Monitoring
|
||||
|
||||
<https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4>
|
||||
|
||||
🌟 More Deployment Cases Await!
|
||||
|
||||
## 📦 Install
|
||||
|
||||
### Install with precompiled binary
|
||||
|
||||
Download the binary for your platform from the [Releases](https://github.com/sipeed/picoclaw/releases) page.
|
||||
|
||||
### Install from source (latest features, recommended for development)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/sipeed/picoclaw.git
|
||||
|
||||
cd picoclaw
|
||||
make deps
|
||||
|
||||
# Build, no need to install
|
||||
make build
|
||||
|
||||
# Build for multiple platforms
|
||||
make build-all
|
||||
|
||||
# Build for Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64)
|
||||
make build-pi-zero
|
||||
|
||||
# Build And Install
|
||||
make install
|
||||
```
|
||||
|
||||
**Raspberry Pi Zero 2 W:** Use the binary that matches your OS: 32-bit Raspberry Pi OS → `make build-linux-arm`; 64-bit → `make build-linux-arm64`. Or run `make build-pi-zero` to build both.
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
For detailed guides, see the docs below. The README covers quick start only.
|
||||
|
||||
```bash
|
||||
# 1. Clone this repo
|
||||
git clone https://github.com/sipeed/picoclaw.git
|
||||
cd picoclaw
|
||||
|
||||
# 2. First run — auto-generates docker/data/config.json then exits
|
||||
docker compose -f docker/docker-compose.yml --profile gateway up
|
||||
# The container prints "First-run setup complete." and stops.
|
||||
|
||||
# 3. Set your API keys
|
||||
vim docker/data/config.json # Set provider API keys, bot tokens, etc.
|
||||
|
||||
# 4. Start
|
||||
docker compose -f docker/docker-compose.yml --profile gateway up -d
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> **Docker Users**: By default, the Gateway listens on `127.0.0.1` which is not accessible from the host. If you need to access the health endpoints or expose ports, set `PICOCLAW_GATEWAY_HOST=0.0.0.0` in your environment or update `config.json`.
|
||||
|
||||
```bash
|
||||
# 5. Check logs
|
||||
docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway
|
||||
|
||||
# 6. Stop
|
||||
docker compose -f docker/docker-compose.yml --profile gateway down
|
||||
```
|
||||
|
||||
### Launcher Mode (Web Console)
|
||||
|
||||
The `launcher` image includes all three binaries (`picoclaw`, `picoclaw-launcher`, `picoclaw-launcher-tui`) and starts the web console by default, which provides a browser-based UI for configuration and chat.
|
||||
|
||||
```bash
|
||||
docker compose -f docker/docker-compose.yml --profile launcher up -d
|
||||
```
|
||||
|
||||
Open http://localhost:18800 in your browser. The launcher manages the gateway process automatically.
|
||||
|
||||
> [!WARNING]
|
||||
> The web console does not yet support authentication. Avoid exposing it to the public internet.
|
||||
|
||||
### Agent Mode (One-shot)
|
||||
|
||||
```bash
|
||||
# Ask a question
|
||||
docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "What is 2+2?"
|
||||
|
||||
# Interactive mode
|
||||
docker compose -f docker/docker-compose.yml run --rm picoclaw-agent
|
||||
```
|
||||
|
||||
### Update
|
||||
|
||||
```bash
|
||||
docker compose -f docker/docker-compose.yml pull
|
||||
docker compose -f docker/docker-compose.yml --profile gateway up -d
|
||||
```
|
||||
|
||||
### 🚀 Quick Start
|
||||
|
||||
> [!TIP]
|
||||
> Set your API Key in `~/.picoclaw/config.json`. Get API Keys: [Volcengine (CodingPlan)](https://console.volcengine.com) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM). Web search is optional — get a free [Tavily API](https://tavily.com) (1000 free queries/month) or [Brave Search API](https://brave.com/search/api) (2000 free queries/month).
|
||||
|
||||
**1. Initialize**
|
||||
|
||||
```bash
|
||||
picoclaw onboard
|
||||
```
|
||||
|
||||
**2. Configure** (`~/.picoclaw/config.json`)
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"workspace": "~/.picoclaw/workspace",
|
||||
"model_name": "gpt-5.4",
|
||||
"max_tokens": 8192,
|
||||
"temperature": 0.7,
|
||||
"max_tool_iterations": 20
|
||||
}
|
||||
},
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "ark-code-latest",
|
||||
"model": "volcengine/ark-code-latest",
|
||||
"api_key": "sk-your-api-key"
|
||||
},
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"api_key": "your-api-key",
|
||||
"request_timeout": 300
|
||||
},
|
||||
{
|
||||
"model_name": "claude-sonnet-4.6",
|
||||
"model": "anthropic/claude-sonnet-4.6",
|
||||
"api_key": "your-anthropic-key"
|
||||
}
|
||||
],
|
||||
"tools": {
|
||||
"web": {
|
||||
"brave": {
|
||||
"enabled": false,
|
||||
"api_key": "YOUR_BRAVE_API_KEY",
|
||||
"max_results": 5
|
||||
},
|
||||
"tavily": {
|
||||
"enabled": false,
|
||||
"api_key": "YOUR_TAVILY_API_KEY",
|
||||
"max_results": 5
|
||||
},
|
||||
"duckduckgo": {
|
||||
"enabled": true,
|
||||
"max_results": 5
|
||||
},
|
||||
"perplexity": {
|
||||
"enabled": false,
|
||||
"api_key": "YOUR_PERPLEXITY_API_KEY",
|
||||
"max_results": 5
|
||||
},
|
||||
"searxng": {
|
||||
"enabled": false,
|
||||
"base_url": "http://your-searxng-instance:8888",
|
||||
"max_results": 5
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **New**: The `model_list` configuration format allows zero-code provider addition. See [Model Configuration](#model-configuration-model_list) for details.
|
||||
> `request_timeout` is optional and uses seconds. If omitted or set to `<= 0`, PicoClaw uses the default timeout (120s).
|
||||
|
||||
**3. Get API Keys**
|
||||
|
||||
* **LLM Provider**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
|
||||
* **Web Search** (optional):
|
||||
* [Brave Search](https://brave.com/search/api) - Paid ($5/1000 queries, ~$5-6/month)
|
||||
* [Perplexity](https://www.perplexity.ai) - AI-powered search with chat interface
|
||||
* [SearXNG](https://github.com/searxng/searxng) - Self-hosted metasearch engine (free, no API key needed)
|
||||
* [Tavily](https://tavily.com) - Optimized for AI Agents (1000 requests/month)
|
||||
* DuckDuckGo - Built-in fallback (no API key required)
|
||||
|
||||
> **Note**: See `config.example.json` for a complete configuration template.
|
||||
|
||||
**4. Chat**
|
||||
|
||||
```bash
|
||||
picoclaw agent -m "What is 2+2?"
|
||||
```
|
||||
|
||||
That's it! You have a working AI assistant in 2 minutes.
|
||||
|
||||
---
|
||||
|
||||
## 💬 Chat Apps
|
||||
|
||||
Talk to your picoclaw through Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, LINE, or WeCom
|
||||
|
||||
> **Note**: All webhook-based channels (LINE, WeCom, etc.) are served on a single shared Gateway HTTP server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). There are no per-channel ports to configure. Note: Feishu uses WebSocket/SDK mode and does not use the shared HTTP webhook server.
|
||||
|
||||
| Channel | Setup |
|
||||
| ------------ | ---------------------------------- |
|
||||
| **Telegram** | Easy (just a token) |
|
||||
| **Discord** | Easy (bot token + intents) |
|
||||
| **WhatsApp** | Easy (native: QR scan; or bridge URL) |
|
||||
| **Matrix** | Medium (homeserver + bot access token) |
|
||||
| **QQ** | Easy (AppID + AppSecret) |
|
||||
| **DingTalk** | Medium (app credentials) |
|
||||
| **LINE** | Medium (credentials + webhook URL) |
|
||||
| **WeCom AI Bot** | Medium (Token + AES key) |
|
||||
|
||||
<details>
|
||||
<summary><b>Telegram</b> (Recommended)</summary>
|
||||
|
||||
**1. Create a bot**
|
||||
|
||||
* Open Telegram, search `@BotFather`
|
||||
* Send `/newbot`, follow prompts
|
||||
* Copy the token
|
||||
|
||||
**2. Configure**
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"telegram": {
|
||||
"enabled": true,
|
||||
"token": "YOUR_BOT_TOKEN",
|
||||
"allow_from": ["YOUR_USER_ID"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> Get your user ID from `@userinfobot` on Telegram.
|
||||
|
||||
**3. Run**
|
||||
|
||||
```bash
|
||||
picoclaw gateway
|
||||
```
|
||||
|
||||
**4. Telegram command menu (auto-registered at startup)**
|
||||
|
||||
PicoClaw now keeps command definitions in one shared registry. On startup, Telegram will automatically register supported bot commands (for example `/start`, `/help`, `/show`, `/list`) so command menu and runtime behavior stay in sync.
|
||||
Telegram command menu registration remains channel-local discovery UX; generic command execution is handled centrally in the agent loop via the commands executor.
|
||||
|
||||
If command registration fails (network/API transient errors), the channel still starts and PicoClaw retries registration in the background.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Discord</b></summary>
|
||||
|
||||
**1. Create a bot**
|
||||
|
||||
* Go to <https://discord.com/developers/applications>
|
||||
* Create an application → Bot → Add Bot
|
||||
* Copy the bot token
|
||||
|
||||
**2. Enable intents**
|
||||
|
||||
* In the Bot settings, enable **MESSAGE CONTENT INTENT**
|
||||
* (Optional) Enable **SERVER MEMBERS INTENT** if you plan to use allow lists based on member data
|
||||
|
||||
**3. Get your User ID**
|
||||
* Discord Settings → Advanced → enable **Developer Mode**
|
||||
* Right-click your avatar → **Copy User ID**
|
||||
|
||||
**4. Configure**
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"discord": {
|
||||
"enabled": true,
|
||||
"token": "YOUR_BOT_TOKEN",
|
||||
"allow_from": ["YOUR_USER_ID"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**5. Invite the bot**
|
||||
|
||||
* OAuth2 → URL Generator
|
||||
* Scopes: `bot`
|
||||
* Bot Permissions: `Send Messages`, `Read Message History`
|
||||
* Open the generated invite URL and add the bot to your server
|
||||
|
||||
**Optional: Group trigger mode**
|
||||
|
||||
By default the bot responds to all messages in a server channel. To restrict responses to @-mentions only, add:
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"discord": {
|
||||
"group_trigger": { "mention_only": true }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
You can also trigger by keyword prefixes (e.g. `!bot`):
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"discord": {
|
||||
"group_trigger": { "prefixes": ["!bot"] }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**6. Run**
|
||||
|
||||
```bash
|
||||
picoclaw gateway
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>WhatsApp</b> (native via whatsmeow)</summary>
|
||||
|
||||
PicoClaw can connect to WhatsApp in two ways:
|
||||
|
||||
- **Native (recommended):** In-process using [whatsmeow](https://github.com/tulir/whatsmeow). No separate bridge. Set `"use_native": true` and leave `bridge_url` empty. On first run, scan the QR code with WhatsApp (Linked Devices). Session is stored under your workspace (e.g. `workspace/whatsapp/`). The native channel is **optional** to keep the default binary small; build with `-tags whatsapp_native` (e.g. `make build-whatsapp-native` or `go build -tags whatsapp_native ./cmd/...`).
|
||||
- **Bridge:** Connect to an external WebSocket bridge. Set `bridge_url` (e.g. `ws://localhost:3001`) and keep `use_native` false.
|
||||
|
||||
**Configure (native)**
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"whatsapp": {
|
||||
"enabled": true,
|
||||
"use_native": true,
|
||||
"session_store_path": "",
|
||||
"allow_from": []
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If `session_store_path` is empty, the session is stored in `<workspace>/whatsapp/`. Run `picoclaw gateway`; on first run, scan the QR code printed in the terminal with WhatsApp → Linked Devices.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>QQ</b></summary>
|
||||
|
||||
**1. Create a bot**
|
||||
|
||||
- Go to [QQ Open Platform](https://q.qq.com/#)
|
||||
- Create an application → Get **AppID** and **AppSecret**
|
||||
|
||||
**2. Configure**
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"qq": {
|
||||
"enabled": true,
|
||||
"app_id": "YOUR_APP_ID",
|
||||
"app_secret": "YOUR_APP_SECRET",
|
||||
"allow_from": []
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> Set `allow_from` to empty to allow all users, or specify QQ numbers to restrict access.
|
||||
|
||||
**3. Run**
|
||||
|
||||
```bash
|
||||
picoclaw gateway
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>DingTalk</b></summary>
|
||||
|
||||
**1. Create a bot**
|
||||
|
||||
* Go to [Open Platform](https://open.dingtalk.com/)
|
||||
* Create an internal app
|
||||
* Copy Client ID and Client Secret
|
||||
|
||||
**2. Configure**
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"dingtalk": {
|
||||
"enabled": true,
|
||||
"client_id": "YOUR_CLIENT_ID",
|
||||
"client_secret": "YOUR_CLIENT_SECRET",
|
||||
"allow_from": []
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> Set `allow_from` to empty to allow all users, or specify DingTalk user IDs to restrict access.
|
||||
|
||||
**3. Run**
|
||||
|
||||
```bash
|
||||
picoclaw gateway
|
||||
```
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Matrix</b></summary>
|
||||
|
||||
**1. Prepare bot account**
|
||||
|
||||
* Use your preferred homeserver (e.g. `https://matrix.org` or self-hosted)
|
||||
* Create a bot user and obtain its access token
|
||||
|
||||
**2. Configure**
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"matrix": {
|
||||
"enabled": true,
|
||||
"homeserver": "https://matrix.org",
|
||||
"user_id": "@your-bot:matrix.org",
|
||||
"access_token": "YOUR_MATRIX_ACCESS_TOKEN",
|
||||
"allow_from": []
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**3. Run**
|
||||
|
||||
```bash
|
||||
picoclaw gateway
|
||||
```
|
||||
|
||||
For full options (`device_id`, `join_on_invite`, `group_trigger`, `placeholder`, `reasoning_channel_id`), see [Matrix Channel Configuration Guide](docs/channels/matrix/README.md).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>LINE</b></summary>
|
||||
|
||||
**1. Create a LINE Official Account**
|
||||
|
||||
- Go to [LINE Developers Console](https://developers.line.biz/)
|
||||
- Create a provider → Create a Messaging API channel
|
||||
- Copy **Channel Secret** and **Channel Access Token**
|
||||
|
||||
**2. Configure**
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"line": {
|
||||
"enabled": true,
|
||||
"channel_secret": "YOUR_CHANNEL_SECRET",
|
||||
"channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN",
|
||||
"webhook_path": "/webhook/line",
|
||||
"allow_from": []
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> LINE webhook is served on the shared Gateway server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`).
|
||||
|
||||
**3. Set up Webhook URL**
|
||||
|
||||
LINE requires HTTPS for webhooks. Use a reverse proxy or tunnel:
|
||||
|
||||
```bash
|
||||
# Example with ngrok (gateway default port is 18790)
|
||||
ngrok http 18790
|
||||
```
|
||||
|
||||
Then set the Webhook URL in LINE Developers Console to `https://your-domain/webhook/line` and enable **Use webhook**.
|
||||
|
||||
**4. Run**
|
||||
|
||||
```bash
|
||||
picoclaw gateway
|
||||
```
|
||||
|
||||
> In group chats, the bot responds only when @mentioned. Replies quote the original message.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>WeCom (企业微信)</b></summary>
|
||||
|
||||
PicoClaw supports three types of WeCom integration:
|
||||
|
||||
**Option 1: WeCom Bot (Bot)** - Easier setup, supports group chats
|
||||
**Option 2: WeCom App (Custom App)** - More features, proactive messaging, private chat only
|
||||
**Option 3: WeCom AI Bot (AI Bot)** - Official AI Bot, streaming replies, supports group & private chat
|
||||
|
||||
See [WeCom AI Bot Configuration Guide](docs/channels/wecom/wecom_aibot/README.zh.md) for detailed setup instructions.
|
||||
|
||||
**Quick Setup - WeCom AI Bot:**
|
||||
|
||||
**1. Create an AI Bot**
|
||||
|
||||
* Go to WeCom Admin Console → AI Bot
|
||||
* Create a new AI Bot → Set name, avatar, etc.
|
||||
* Copy **Bot ID** and **Secret**
|
||||
|
||||
**2. Configure**
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"wecom_aibot": {
|
||||
"enabled": true,
|
||||
"bot_id": "YOUR_BOT_ID",
|
||||
"secret": "YOUR_SECRET",
|
||||
"allow_from": [],
|
||||
"welcome_message": "Hello! How can I help you?"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**3. Run**
|
||||
|
||||
```bash
|
||||
picoclaw gateway
|
||||
```
|
||||
|
||||
> **Note**: WeCom AI Bot uses streaming pull protocol — no reply timeout concerns. Long tasks (>30 seconds) automatically switch to `response_url` push delivery.
|
||||
|
||||
</details>
|
||||
|
||||
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> Join the Agent Social Network
|
||||
|
||||
Connect Picoclaw to the Agent Social Network simply by sending a single message via the CLI or any integrated Chat App.
|
||||
|
||||
**Read `https://clawdchat.ai/skill.md` and follow the instructions to join [ClawdChat.ai](https://clawdchat.ai)**
|
||||
|
||||
## 🖥️ CLI Reference
|
||||
|
||||
| Command | Description |
|
||||
| ------------------------- | ----------------------------- |
|
||||
| `picoclaw onboard` | Initialize config & workspace |
|
||||
| `picoclaw agent -m "..."` | Chat with the agent |
|
||||
| `picoclaw agent` | Interactive chat mode |
|
||||
| `picoclaw gateway` | Start the gateway |
|
||||
| `picoclaw status` | Show status |
|
||||
| `picoclaw version` | Show version info |
|
||||
| `picoclaw cron list` | List all scheduled jobs |
|
||||
| `picoclaw cron add ...` | Add a scheduled job |
|
||||
| `picoclaw cron disable` | Disable a scheduled job |
|
||||
| `picoclaw cron remove` | Remove a scheduled job |
|
||||
| `picoclaw skills list` | List installed skills |
|
||||
| `picoclaw skills install` | Install a skill |
|
||||
| `picoclaw migrate` | Migrate data from older versions |
|
||||
| `picoclaw auth login` | Authenticate with providers |
|
||||
|
||||
### Scheduled Tasks / Reminders
|
||||
|
||||
PicoClaw supports scheduled reminders and recurring tasks through the `cron` tool:
|
||||
|
||||
* **One-time reminders**: "Remind me in 10 minutes" → triggers once after 10min
|
||||
* **Recurring tasks**: "Remind me every 2 hours" → triggers every 2 hours
|
||||
* **Cron expressions**: "Remind me at 9am daily" → uses cron expression
|
||||
|
||||
## 🤝 Contribute & Roadmap
|
||||
|
||||
PRs welcome! The codebase is intentionally small and readable. 🤗
|
||||
|
||||
See our full [Community Roadmap](https://github.com/sipeed/picoclaw/blob/main/ROADMAP.md).
|
||||
|
||||
Developer group building, join after your first merged PR!
|
||||
|
||||
User Groups:
|
||||
|
||||
discord: <https://discord.gg/V4sAZ9XWpN>
|
||||
|
||||
<img src="assets/wechat.png" alt="PicoClaw" width="512">
|
||||
|
||||
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> Join the Agent Social Network
|
||||
|
||||
Connect Picoclaw to the Agent Social Network simply by sending a single message via the CLI or any integrated Chat App.
|
||||
|
||||
**Read `https://clawdchat.ai/skill.md` and follow the instructions to join [ClawdChat.ai](https://clawdchat.ai)**
|
||||
|
||||
## 🖥️ CLI Reference
|
||||
|
||||
| Command | Description |
|
||||
| ------------------------- | ----------------------------- |
|
||||
| `picoclaw onboard` | Initialize config & workspace |
|
||||
| `picoclaw agent -m "..."` | Chat with the agent |
|
||||
| `picoclaw agent` | Interactive chat mode |
|
||||
| `picoclaw gateway` | Start the gateway |
|
||||
| `picoclaw status` | Show status |
|
||||
| `picoclaw version` | Show version info |
|
||||
| `picoclaw cron list` | List all scheduled jobs |
|
||||
| `picoclaw cron add ...` | Add a scheduled job |
|
||||
| `picoclaw cron disable` | Disable a scheduled job |
|
||||
| `picoclaw cron remove` | Remove a scheduled job |
|
||||
| `picoclaw skills list` | List installed skills |
|
||||
| `picoclaw skills install` | Install a skill |
|
||||
| `picoclaw migrate` | Migrate data from older versions |
|
||||
| `picoclaw auth login` | Authenticate with providers |
|
||||
| `picoclaw model` | View or switch the default model |
|
||||
|
||||
### Scheduled Tasks / Reminders
|
||||
|
||||
PicoClaw supports scheduled reminders and recurring tasks through the `cron` tool:
|
||||
|
||||
* **One-time reminders**: "Remind me in 10 minutes" → triggers once after 10min
|
||||
* **Recurring tasks**: "Remind me every 2 hours" → triggers every 2 hours
|
||||
* **Cron expressions**: "Remind me at 9am daily" → uses cron expression
|
||||
|
||||
## 🤝 Contribute & Roadmap
|
||||
|
||||
PRs welcome! The codebase is intentionally small and readable. 🤗
|
||||
|
||||
See our full [Community Roadmap](https://github.com/sipeed/picoclaw/blob/main/ROADMAP.md).
|
||||
|
||||
Developer group building, join after your first merged PR!
|
||||
|
||||
User Groups:
|
||||
|
||||
discord: <https://discord.gg/V4sAZ9XWpN>
|
||||
|
||||
<img src="assets/wechat.png" alt="PicoClaw" width="512">
|
||||
WeChat:
|
||||
<img src="assets/wechat.png" alt="WeChat group QR code" width="512">
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 158 KiB After Width: | Height: | Size: 61 KiB |
|
|
@ -23,16 +23,16 @@ func agentCmd(message, sessionKey, model string, debug bool) error {
|
|||
sessionKey = "cli:default"
|
||||
}
|
||||
|
||||
if debug {
|
||||
logger.SetLevel(logger.DEBUG)
|
||||
fmt.Println("🔍 Debug mode enabled")
|
||||
}
|
||||
|
||||
cfg, err := internal.LoadConfig()
|
||||
if err != nil {
|
||||
return fmt.Errorf("error loading config: %w", err)
|
||||
}
|
||||
|
||||
if debug {
|
||||
logger.SetLevel(logger.DEBUG)
|
||||
fmt.Println("🔍 Debug mode enabled")
|
||||
}
|
||||
|
||||
if model != "" {
|
||||
cfg.Agents.Defaults.ModelName = model
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"path/filepath"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
)
|
||||
|
||||
const Logo = "🦞"
|
||||
|
|
@ -27,7 +28,12 @@ func GetConfigPath() string {
|
|||
}
|
||||
|
||||
func LoadConfig() (*config.Config, error) {
|
||||
return config.LoadConfig(GetConfigPath())
|
||||
cfg, err := config.LoadConfig(GetConfigPath())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
logger.SetLevelFromString(cfg.Agents.Defaults.LogLevel)
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// FormatVersion returns the version string with optional git commit
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"log_level": "fatal",
|
||||
"workspace": "~/.picoclaw/workspace",
|
||||
"restrict_to_workspace": true,
|
||||
"model_name": "gpt-5.4",
|
||||
|
|
|
|||
67
docker/Dockerfile.heavy
Normal file
67
docker/Dockerfile.heavy
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
# ============================================================
|
||||
# Stage 1: Build the picoclaw binary
|
||||
# ============================================================
|
||||
FROM golang:1.26.0-alpine AS builder
|
||||
|
||||
RUN apk add --no-cache git make
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
# Cache dependencies
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
# Copy source and build
|
||||
COPY . .
|
||||
RUN make build
|
||||
|
||||
# ============================================================
|
||||
# Stage 2: Node.js runtime with Python + MCP support
|
||||
# ============================================================
|
||||
FROM node:24-alpine3.23
|
||||
|
||||
RUN apk add --no-cache \
|
||||
ca-certificates \
|
||||
curl \
|
||||
git \
|
||||
python3 \
|
||||
py3-pip \
|
||||
chromium \
|
||||
jq
|
||||
|
||||
# Install Playwright browsers for agent-browser
|
||||
ENV PLAYWRIGHT_BROWSERS_PATH=/opt/playwright-browsers
|
||||
RUN npm install -g agent-browser && \
|
||||
npx playwright install chromium && \
|
||||
chmod -R o+rx $PLAYWRIGHT_BROWSERS_PATH
|
||||
|
||||
# Install uv
|
||||
RUN curl -LsSf https://astral.sh/uv/install.sh | sh && \
|
||||
ln -s /root/.local/bin/uv /usr/local/bin/uv && \
|
||||
ln -s /root/.local/bin/uvx /usr/local/bin/uvx && \
|
||||
uv --version
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget -q --spider http://localhost:18790/health || exit 1
|
||||
|
||||
# Copy binary
|
||||
COPY --from=builder /src/build/picoclaw /usr/local/bin/picoclaw
|
||||
|
||||
# Reuse existing node user (UID/GID 1000) — rename to picoclaw
|
||||
RUN deluser node 2>/dev/null; delgroup node 2>/dev/null; \
|
||||
addgroup -g 1000 picoclaw 2>/dev/null; \
|
||||
adduser -D -u 1000 -G picoclaw -h /home/picoclaw picoclaw 2>/dev/null || true
|
||||
|
||||
USER picoclaw
|
||||
|
||||
# Run onboard to create initial directories and config
|
||||
RUN /usr/local/bin/picoclaw onboard
|
||||
|
||||
# Copy default workspace
|
||||
COPY --chown=picoclaw:picoclaw workspace/ /home/picoclaw/.picoclaw/workspace/
|
||||
|
||||
VOLUME /home/picoclaw/.picoclaw/workspace
|
||||
|
||||
ENTRYPOINT ["picoclaw"]
|
||||
CMD ["gateway"]
|
||||
3
go.mod
3
go.mod
|
|
@ -3,13 +3,12 @@ module github.com/sipeed/picoclaw
|
|||
go 1.25.8
|
||||
|
||||
require (
|
||||
fyne.io/systray v1.12.0
|
||||
github.com/BurntSushi/toml v1.6.0
|
||||
fyne.io/systray v1.12.0
|
||||
github.com/adhocore/gronx v1.19.6
|
||||
github.com/anthropics/anthropic-sdk-go v1.26.0
|
||||
github.com/bwmarrin/discordgo v0.29.0
|
||||
github.com/caarlos0/env/v11 v11.4.0
|
||||
github.com/creack/pty v1.1.9
|
||||
github.com/ergochat/irc-go v0.6.0
|
||||
github.com/ergochat/readline v0.1.3
|
||||
github.com/gdamore/tcell/v2 v2.13.8
|
||||
|
|
|
|||
1
go.sum
1
go.sum
|
|
@ -37,7 +37,6 @@ github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9
|
|||
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/creack/pty v1.1.9 h1:uDmaGzcdjhF4i/plgjmEsriH11Y0o7RKapEf/LDaM3w=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
|
|
|
|||
|
|
@ -236,9 +236,8 @@ func TestNewAgentInstance_AllowsMediaTempDirForReadListAndExec(t *testing.T) {
|
|||
t.Fatal("exec tool not registered")
|
||||
}
|
||||
execResult := execTool.Execute(context.Background(), map[string]any{
|
||||
"action": "run",
|
||||
"command": "cat " + filepath.Base(mediaPath),
|
||||
"cwd": mediaDir,
|
||||
"command": "cat " + filepath.Base(mediaPath),
|
||||
"working_dir": mediaDir,
|
||||
})
|
||||
if execResult.IsError {
|
||||
t.Fatalf("exec should allow media temp dir, got: %s", execResult.ForLLM)
|
||||
|
|
|
|||
|
|
@ -253,6 +253,7 @@ type AgentDefaults struct {
|
|||
SteeringMode string `json:"steering_mode,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_STEERING_MODE"` // "one-at-a-time" (default) or "all"
|
||||
SubTurn SubTurnConfig `json:"subturn" envPrefix:"PICOCLAW_AGENTS_DEFAULTS_SUBTURN_"`
|
||||
ToolFeedback ToolFeedbackConfig `json:"tool_feedback,omitempty"`
|
||||
LogLevel string `json:"log_level,omitempty" env:"PICOCLAW_LOG_LEVEL"`
|
||||
}
|
||||
|
||||
const (
|
||||
|
|
|
|||
|
|
@ -470,6 +470,13 @@ func TestDefaultConfig_CronAllowCommandEnabled(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestDefaultConfig_LogLevel(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
if cfg.Agents.Defaults.LogLevel != "fatal" {
|
||||
t.Errorf("LogLevel = %q, want \"fatal\"", cfg.Agents.Defaults.LogLevel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfig_OpenAIWebSearchDefaultsTrueWhenUnset(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "config.json")
|
||||
|
|
@ -1057,3 +1064,38 @@ func TestLoadConfig_UsesPassphraseProvider(t *testing.T) {
|
|||
t.Errorf("api_key = %q, want %q", cfg.ModelList[0].APIKey, plainKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigParsesLogLevel(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfgPath := filepath.Join(dir, "config.json")
|
||||
data := `{"agents":{"defaults":{"log_level":"debug"}}}`
|
||||
if err := os.WriteFile(cfgPath, []byte(data), 0o600); err != nil {
|
||||
t.Fatalf("setup: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := LoadConfig(cfgPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig: %v", err)
|
||||
}
|
||||
if cfg.Agents.Defaults.LogLevel != "debug" {
|
||||
t.Errorf("LogLevel = %q, want \"debug\"", cfg.Agents.Defaults.LogLevel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigLogLevelEmpty(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfgPath := filepath.Join(dir, "config.json")
|
||||
data := `{}`
|
||||
if err := os.WriteFile(cfgPath, []byte(data), 0o600); err != nil {
|
||||
t.Fatalf("setup: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := LoadConfig(cfgPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig: %v", err)
|
||||
}
|
||||
// When config omits log_level, the DefaultConfig value ("fatal") is preserved.
|
||||
if cfg.Agents.Defaults.LogLevel != "fatal" {
|
||||
t.Errorf("LogLevel = %q, want \"fatal\"", cfg.Agents.Defaults.LogLevel)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ func DefaultConfig() *Config {
|
|||
return &Config{
|
||||
Agents: AgentsConfig{
|
||||
Defaults: AgentDefaults{
|
||||
LogLevel: "fatal",
|
||||
Workspace: workspacePath,
|
||||
RestrictToWorkspace: true,
|
||||
Provider: "",
|
||||
|
|
|
|||
|
|
@ -79,16 +79,18 @@ func (p *startupBlockedProvider) GetDefaultModel() string {
|
|||
|
||||
// Run starts the gateway runtime using the configuration loaded from configPath.
|
||||
func Run(debug bool, configPath string, allowEmptyStartup bool) error {
|
||||
if debug {
|
||||
logger.SetLevel(logger.DEBUG)
|
||||
fmt.Println("🔍 Debug mode enabled")
|
||||
}
|
||||
|
||||
cfg, err := config.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error loading config: %w", err)
|
||||
}
|
||||
|
||||
logger.SetLevelFromString(cfg.Agents.Defaults.LogLevel)
|
||||
|
||||
if debug {
|
||||
logger.SetLevel(logger.DEBUG)
|
||||
fmt.Println("🔍 Debug mode enabled")
|
||||
}
|
||||
|
||||
provider, modelID, err := createStartupProvider(cfg, allowEmptyStartup)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error creating provider: %w", err)
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import (
|
|||
const (
|
||||
minIntervalMinutes = 5
|
||||
defaultIntervalMinutes = 30
|
||||
userTasksMarker = "Add your heartbeat tasks below this line:"
|
||||
)
|
||||
|
||||
// HeartbeatHandler is the function type for handling heartbeat.
|
||||
|
|
@ -232,7 +233,7 @@ func (hs *HeartbeatService) buildPrompt() string {
|
|||
}
|
||||
|
||||
content := string(data)
|
||||
if len(content) == 0 {
|
||||
if !heartbeatHasUserTasks(content) {
|
||||
return ""
|
||||
}
|
||||
|
||||
|
|
@ -284,6 +285,32 @@ Add your heartbeat tasks below this line:
|
|||
}
|
||||
}
|
||||
|
||||
func heartbeatHasUserTasks(content string) bool {
|
||||
trimmed := strings.TrimSpace(content)
|
||||
if trimmed == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
markerIdx := strings.Index(content, userTasksMarker)
|
||||
if markerIdx < 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
tasksSection := content[markerIdx+len(userTasksMarker):]
|
||||
for _, line := range strings.Split(tasksSection, "\n") {
|
||||
trimmedLine := strings.TrimSpace(line)
|
||||
if trimmedLine == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(trimmedLine, "#") {
|
||||
continue
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// sendResponse sends the heartbeat response to the last channel
|
||||
func (hs *HeartbeatService) sendResponse(response string) {
|
||||
hs.mu.RLock()
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package heartbeat
|
|||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
|
|
@ -203,3 +204,47 @@ func TestHeartbeatFilePath(t *testing.T) {
|
|||
t.Errorf("Expected HEARTBEAT.md at %s, but it doesn't exist", expectedPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPrompt_DefaultTemplateStaysIdle(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "heartbeat-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
hs := NewHeartbeatService(tmpDir, 30, true)
|
||||
hs.createDefaultHeartbeatTemplate()
|
||||
|
||||
if prompt := hs.buildPrompt(); prompt != "" {
|
||||
t.Fatalf("buildPrompt() = %q, want empty prompt for untouched default template", prompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPrompt_UserTasksAfterMarkerProducePrompt(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "heartbeat-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
hs := NewHeartbeatService(tmpDir, 30, true)
|
||||
hs.createDefaultHeartbeatTemplate()
|
||||
|
||||
path := filepath.Join(tmpDir, "HEARTBEAT.md")
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read HEARTBEAT.md: %v", err)
|
||||
}
|
||||
updated := string(data) + "\n- Check unread Feishu messages\n"
|
||||
if err := os.WriteFile(path, []byte(updated), 0o644); err != nil {
|
||||
t.Fatalf("Failed to update HEARTBEAT.md: %v", err)
|
||||
}
|
||||
|
||||
prompt := hs.buildPrompt()
|
||||
if prompt == "" {
|
||||
t.Fatal("buildPrompt() = empty, want non-empty prompt when user tasks are present")
|
||||
}
|
||||
if !strings.Contains(prompt, "Check unread Feishu messages") {
|
||||
t.Fatalf("prompt = %q, want user task content", prompt)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -94,13 +94,18 @@ func MatchAllowed(sender bus.SenderInfo, allowed string) bool {
|
|||
return false
|
||||
}
|
||||
|
||||
// isNumeric returns true if s consists entirely of digits.
|
||||
// isNumeric returns true if s consists entirely of digits, allowing for an optional leading minus sign
|
||||
// (required for Telegram group/channel IDs like -1001234567890).
|
||||
func isNumeric(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range s {
|
||||
if r < '0' || r > '9' {
|
||||
start := 0
|
||||
if s[0] == '-' && len(s) > 1 {
|
||||
start = 1
|
||||
}
|
||||
for i := start; i < len(s); i++ {
|
||||
if s[i] < '0' || s[i] > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -97,6 +97,15 @@ func TestMatchAllowed(t *testing.T) {
|
|||
allowed: "654321",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "negative numeric ID matches PlatformID",
|
||||
sender: bus.SenderInfo{
|
||||
Platform: "telegram",
|
||||
PlatformID: "-1001234567890",
|
||||
},
|
||||
allowed: "-1001234567890",
|
||||
want: true,
|
||||
},
|
||||
// Username matching
|
||||
{
|
||||
name: "@username matches Username",
|
||||
|
|
@ -238,6 +247,9 @@ func TestIsNumeric(t *testing.T) {
|
|||
{"abc", false},
|
||||
{"12a34", false},
|
||||
{"telegram", false},
|
||||
{"-1001234567890", true},
|
||||
{"-", false},
|
||||
{"-12a34", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
|
|
|||
|
|
@ -106,6 +106,36 @@ func GetLevel() LogLevel {
|
|||
return currentLevel
|
||||
}
|
||||
|
||||
// ParseLevel converts a case-insensitive level name to a LogLevel.
|
||||
// Returns the level and true if valid, or (INFO, false) if unrecognized.
|
||||
func ParseLevel(s string) (LogLevel, bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||
case "debug":
|
||||
return DEBUG, true
|
||||
case "info":
|
||||
return INFO, true
|
||||
case "warn", "warning":
|
||||
return WARN, true
|
||||
case "error":
|
||||
return ERROR, true
|
||||
case "fatal":
|
||||
return FATAL, true
|
||||
default:
|
||||
return INFO, false
|
||||
}
|
||||
}
|
||||
|
||||
// SetLevelFromString sets the log level from a string value.
|
||||
// If the string is empty or not a recognized level name, the current level is kept.
|
||||
func SetLevelFromString(s string) {
|
||||
if s == "" {
|
||||
return
|
||||
}
|
||||
if level, ok := ParseLevel(s); ok {
|
||||
SetLevel(level)
|
||||
}
|
||||
}
|
||||
|
||||
func EnableFileLogging(filePath string) error {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
|
|
|||
|
|
@ -252,3 +252,88 @@ func TestFormatFieldValue(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultLevelIsInfo(t *testing.T) {
|
||||
// The package-level default (before any SetLevel call) should be INFO.
|
||||
// Because earlier tests may have changed it, we just verify the constant is wired correctly.
|
||||
if logLevelNames[INFO] != "INFO" {
|
||||
t.Errorf("INFO constant mapped to %q, want \"INFO\"", logLevelNames[INFO])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLevelValid(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want LogLevel
|
||||
}{
|
||||
{"debug", DEBUG},
|
||||
{"DEBUG", DEBUG},
|
||||
{"Debug", DEBUG},
|
||||
{"info", INFO},
|
||||
{"INFO", INFO},
|
||||
{"warn", WARN},
|
||||
{"WARN", WARN},
|
||||
{"warning", WARN},
|
||||
{"WARNING", WARN},
|
||||
{"error", ERROR},
|
||||
{"ERROR", ERROR},
|
||||
{"fatal", FATAL},
|
||||
{"FATAL", FATAL},
|
||||
{" info ", INFO},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got, ok := ParseLevel(tt.input)
|
||||
if !ok {
|
||||
t.Fatalf("ParseLevel(%q) returned ok=false, want true", tt.input)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("ParseLevel(%q) = %v, want %v", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLevelInvalid(t *testing.T) {
|
||||
tests := []string{"", "garbage", "verbose", "trace", "critical"}
|
||||
|
||||
for _, input := range tests {
|
||||
t.Run(input, func(t *testing.T) {
|
||||
_, ok := ParseLevel(input)
|
||||
if ok {
|
||||
t.Errorf("ParseLevel(%q) returned ok=true, want false", input)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetLevelFromString(t *testing.T) {
|
||||
initialLevel := GetLevel()
|
||||
defer SetLevel(initialLevel)
|
||||
|
||||
// Valid string changes the level
|
||||
SetLevel(INFO)
|
||||
SetLevelFromString("error")
|
||||
if got := GetLevel(); got != ERROR {
|
||||
t.Errorf("after SetLevelFromString(\"error\"): GetLevel() = %v, want ERROR", got)
|
||||
}
|
||||
|
||||
// Empty string is a no-op
|
||||
SetLevelFromString("")
|
||||
if got := GetLevel(); got != ERROR {
|
||||
t.Errorf("after SetLevelFromString(\"\"): GetLevel() = %v, want ERROR (unchanged)", got)
|
||||
}
|
||||
|
||||
// Invalid string is a no-op
|
||||
SetLevelFromString("garbage")
|
||||
if got := GetLevel(); got != ERROR {
|
||||
t.Errorf("after SetLevelFromString(\"garbage\"): GetLevel() = %v, want ERROR (unchanged)", got)
|
||||
}
|
||||
|
||||
// Case-insensitive
|
||||
SetLevelFromString("FATAL")
|
||||
if got := GetLevel(); got != FATAL {
|
||||
t.Errorf("after SetLevelFromString(\"FATAL\"): GetLevel() = %v, want FATAL", got)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,252 +0,0 @@
|
|||
package tools
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const maxOutputBufferSize = 100 * 1024 * 1024 // 100MB
|
||||
|
||||
const outputTruncateMarker = "\n... [output truncated, exceeded 100MB]\n"
|
||||
|
||||
// PtyKeyMode represents arrow key encoding mode for PTY sessions.
|
||||
// Programs send smkx/rmkx sequences to switch between CSI and SS3 modes.
|
||||
type PtyKeyMode uint8
|
||||
|
||||
const (
|
||||
PtyKeyModeCSI PtyKeyMode = iota // triggered by rmkx (\x1b[?1l)
|
||||
PtyKeyModeSS3 // triggered by smkx (\x1b[?1h)
|
||||
)
|
||||
|
||||
const PtyKeyModeNotFound PtyKeyMode = 255
|
||||
|
||||
var (
|
||||
ErrSessionNotFound = errors.New("session not found")
|
||||
ErrSessionDone = errors.New("session already completed")
|
||||
ErrPTYNotSupported = errors.New("PTY is not supported on this platform")
|
||||
ErrNoStdin = errors.New("no stdin available")
|
||||
)
|
||||
|
||||
type ProcessSession struct {
|
||||
mu sync.Mutex
|
||||
ID string
|
||||
PID int
|
||||
Command string
|
||||
PTY bool
|
||||
Background bool
|
||||
StartTime int64
|
||||
ExitCode int
|
||||
Status string
|
||||
stdinWriter io.Writer
|
||||
stdoutPipe io.Reader
|
||||
outputBuffer *bytes.Buffer
|
||||
outputTruncated bool
|
||||
ptyMaster *os.File
|
||||
|
||||
// ptyKeyMode tracks arrow key encoding mode (CSI vs SS3)
|
||||
ptyKeyMode PtyKeyMode
|
||||
}
|
||||
|
||||
func (s *ProcessSession) IsDone() bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.Status == "done" || s.Status == "exited"
|
||||
}
|
||||
|
||||
func (s *ProcessSession) GetPtyKeyMode() PtyKeyMode {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.ptyKeyMode
|
||||
}
|
||||
|
||||
func (s *ProcessSession) SetPtyKeyMode(mode PtyKeyMode) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.ptyKeyMode = mode
|
||||
}
|
||||
|
||||
func (s *ProcessSession) GetStatus() string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.Status
|
||||
}
|
||||
|
||||
func (s *ProcessSession) SetStatus(status string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.Status = status
|
||||
}
|
||||
|
||||
func (s *ProcessSession) GetExitCode() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.ExitCode
|
||||
}
|
||||
|
||||
func (s *ProcessSession) SetExitCode(code int) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.ExitCode = code
|
||||
}
|
||||
|
||||
func (s *ProcessSession) killProcess() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.Status != "running" {
|
||||
return ErrSessionDone
|
||||
}
|
||||
|
||||
pid := s.PID
|
||||
if pid <= 0 {
|
||||
return ErrSessionNotFound
|
||||
}
|
||||
|
||||
if err := killProcessGroup(pid); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.Status = "done"
|
||||
s.ExitCode = -1
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ProcessSession) Kill() error {
|
||||
return s.killProcess()
|
||||
}
|
||||
|
||||
func (s *ProcessSession) Write(data string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.Status != "running" {
|
||||
return ErrSessionDone
|
||||
}
|
||||
|
||||
var writer io.Writer
|
||||
if s.PTY && s.ptyMaster != nil {
|
||||
writer = s.ptyMaster
|
||||
} else if s.stdinWriter != nil {
|
||||
writer = s.stdinWriter
|
||||
} else {
|
||||
return ErrNoStdin
|
||||
}
|
||||
|
||||
_, err := writer.Write([]byte(data))
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *ProcessSession) Read() string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.outputBuffer.Len() == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
data := s.outputBuffer.String()
|
||||
s.outputBuffer.Reset()
|
||||
return data
|
||||
}
|
||||
|
||||
func (s *ProcessSession) ToSessionInfo() SessionInfo {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
return SessionInfo{
|
||||
ID: s.ID,
|
||||
Command: s.Command,
|
||||
Status: s.Status,
|
||||
PID: s.PID,
|
||||
StartedAt: s.StartTime,
|
||||
}
|
||||
}
|
||||
|
||||
type SessionManager struct {
|
||||
mu sync.RWMutex
|
||||
sessions map[string]*ProcessSession
|
||||
}
|
||||
|
||||
func NewSessionManager() *SessionManager {
|
||||
sm := &SessionManager{
|
||||
sessions: make(map[string]*ProcessSession),
|
||||
}
|
||||
|
||||
// Start cleaner goroutine - runs every 5 minutes, cleans up sessions done for >30 minutes
|
||||
go func() {
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
sm.cleanupOldSessions()
|
||||
}
|
||||
}()
|
||||
|
||||
return sm
|
||||
}
|
||||
|
||||
// cleanupOldSessions removes sessions that are done and older than 30 minutes
|
||||
func (sm *SessionManager) cleanupOldSessions() {
|
||||
sm.mu.Lock()
|
||||
defer sm.mu.Unlock()
|
||||
|
||||
cutoff := time.Now().Add(-30 * time.Minute)
|
||||
for id, session := range sm.sessions {
|
||||
if session.IsDone() && session.StartTime < cutoff.Unix() {
|
||||
delete(sm.sessions, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (sm *SessionManager) Add(session *ProcessSession) {
|
||||
sm.mu.Lock()
|
||||
defer sm.mu.Unlock()
|
||||
sm.sessions[session.ID] = session
|
||||
}
|
||||
|
||||
func (sm *SessionManager) Get(sessionID string) (*ProcessSession, error) {
|
||||
sm.mu.RLock()
|
||||
defer sm.mu.RUnlock()
|
||||
|
||||
session, ok := sm.sessions[sessionID]
|
||||
if !ok {
|
||||
return nil, ErrSessionNotFound
|
||||
}
|
||||
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func (sm *SessionManager) Remove(sessionID string) {
|
||||
sm.mu.Lock()
|
||||
defer sm.mu.Unlock()
|
||||
delete(sm.sessions, sessionID)
|
||||
}
|
||||
|
||||
func (sm *SessionManager) List() []SessionInfo {
|
||||
sm.mu.RLock()
|
||||
defer sm.mu.RUnlock()
|
||||
|
||||
result := make([]SessionInfo, 0, len(sm.sessions))
|
||||
for _, session := range sm.sessions {
|
||||
result = append(result, session.ToSessionInfo())
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func generateSessionID() string {
|
||||
return uuid.New().String()[:8]
|
||||
}
|
||||
|
||||
type SessionInfo struct {
|
||||
ID string `json:"id"`
|
||||
Command string `json:"command"`
|
||||
Status string `json:"status"`
|
||||
PID int `json:"pid"`
|
||||
StartedAt int64 `json:"startedAt"`
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
//go:build !windows
|
||||
|
||||
package tools
|
||||
|
||||
import (
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func killProcessGroup(pid int) error {
|
||||
if err := syscall.Kill(-pid, syscall.SIGKILL); err != nil {
|
||||
_ = syscall.Kill(pid, syscall.SIGKILL)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
//go:build windows
|
||||
|
||||
package tools
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func killProcessGroup(pid int) error {
|
||||
_ = exec.Command("taskkill", "/T", "/F", "/PID", strconv.Itoa(pid)).Run()
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1,99 +0,0 @@
|
|||
package tools
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSessionManager_AddGet(t *testing.T) {
|
||||
sm := NewSessionManager()
|
||||
session := &ProcessSession{
|
||||
ID: "test-1",
|
||||
Command: "echo hello",
|
||||
Status: "running",
|
||||
StartTime: 1000,
|
||||
}
|
||||
|
||||
sm.Add(session)
|
||||
|
||||
got, err := sm.Get("test-1")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "test-1", got.ID)
|
||||
}
|
||||
|
||||
func TestSessionManager_Remove(t *testing.T) {
|
||||
sm := NewSessionManager()
|
||||
session := &ProcessSession{
|
||||
ID: "test-1",
|
||||
Command: "echo hello",
|
||||
Status: "running",
|
||||
StartTime: 1000,
|
||||
}
|
||||
sm.Add(session)
|
||||
sm.Remove("test-1")
|
||||
|
||||
_, err := sm.Get("test-1")
|
||||
require.ErrorIs(t, err, ErrSessionNotFound)
|
||||
}
|
||||
|
||||
func TestSessionManager_List(t *testing.T) {
|
||||
sm := NewSessionManager()
|
||||
sm.Add(&ProcessSession{
|
||||
ID: "test-1",
|
||||
Command: "echo hello",
|
||||
Status: "running",
|
||||
StartTime: 1000,
|
||||
})
|
||||
sm.Add(&ProcessSession{
|
||||
ID: "test-2",
|
||||
Command: "echo world",
|
||||
Status: "running",
|
||||
StartTime: 1001,
|
||||
})
|
||||
sm.Add(&ProcessSession{
|
||||
ID: "test-3",
|
||||
Command: "echo done",
|
||||
Status: "done",
|
||||
StartTime: 1002,
|
||||
})
|
||||
|
||||
sessions := sm.List()
|
||||
require.Len(t, sessions, 3)
|
||||
|
||||
ids := make(map[string]bool)
|
||||
for _, s := range sessions {
|
||||
ids[s.ID] = true
|
||||
}
|
||||
require.True(t, ids["test-1"])
|
||||
require.True(t, ids["test-2"])
|
||||
require.True(t, ids["test-3"])
|
||||
}
|
||||
|
||||
func TestProcessSession_IsDone(t *testing.T) {
|
||||
session := &ProcessSession{Status: "running"}
|
||||
require.False(t, session.IsDone())
|
||||
|
||||
session.Status = "done"
|
||||
require.True(t, session.IsDone())
|
||||
|
||||
session.Status = "exited"
|
||||
require.True(t, session.IsDone())
|
||||
}
|
||||
|
||||
func TestProcessSession_ToSessionInfo(t *testing.T) {
|
||||
session := &ProcessSession{
|
||||
ID: "test-1",
|
||||
PID: 12345,
|
||||
Command: "echo hello",
|
||||
Status: "running",
|
||||
StartTime: 1000,
|
||||
}
|
||||
|
||||
info := session.ToSessionInfo()
|
||||
require.Equal(t, "test-1", info.ID)
|
||||
require.Equal(t, "echo hello", info.Command)
|
||||
require.Equal(t, "running", info.Status)
|
||||
require.Equal(t, 12345, info.PID)
|
||||
require.Equal(t, int64(1000), info.StartedAt)
|
||||
}
|
||||
|
|
@ -3,37 +3,20 @@ package tools
|
|||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/creack/pty"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/constants"
|
||||
)
|
||||
|
||||
var (
|
||||
globalSessionManager = NewSessionManager()
|
||||
sessionManagerMu sync.RWMutex
|
||||
)
|
||||
|
||||
func getSessionManager() *SessionManager {
|
||||
sessionManagerMu.RLock()
|
||||
defer sessionManagerMu.RUnlock()
|
||||
return globalSessionManager
|
||||
}
|
||||
|
||||
type ExecTool struct {
|
||||
workingDir string
|
||||
timeout time.Duration
|
||||
|
|
@ -43,7 +26,6 @@ type ExecTool struct {
|
|||
allowedPathPatterns []*regexp.Regexp
|
||||
restrictToWorkspace bool
|
||||
allowRemote bool
|
||||
sessionManager *SessionManager
|
||||
}
|
||||
|
||||
var (
|
||||
|
|
@ -163,7 +145,7 @@ func NewExecToolWithConfig(
|
|||
denyPatterns = append(denyPatterns, defaultDenyPatterns...)
|
||||
}
|
||||
|
||||
var timeout time.Duration
|
||||
timeout := 60 * time.Second
|
||||
if config != nil && config.Tools.Exec.TimeoutSeconds > 0 {
|
||||
timeout = time.Duration(config.Tools.Exec.TimeoutSeconds) * time.Second
|
||||
}
|
||||
|
|
@ -177,7 +159,6 @@ func NewExecToolWithConfig(
|
|||
allowedPathPatterns: allowedPathPatterns,
|
||||
restrictToWorkspace: restrict,
|
||||
allowRemote: allowRemote,
|
||||
sessionManager: getSessionManager(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
|
@ -186,146 +167,27 @@ func (t *ExecTool) Name() string {
|
|||
}
|
||||
|
||||
func (t *ExecTool) Description() string {
|
||||
return `Execute shell commands. Use background=true for long-running commands (returns sessionId). Use pty=true for interactive commands (can combine with background=true). Use poll/read/write/send-keys/kill with sessionId to manage background sessions. Sessions auto-cleanup 30 minutes after process exits; use kill to terminate early. Output buffer limit: 100MB.`
|
||||
return "Execute a shell command and return its output. Use with caution."
|
||||
}
|
||||
|
||||
func (t *ExecTool) Parameters() map[string]any {
|
||||
return map[string]any{
|
||||
"oneOf": []map[string]any{
|
||||
{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"action": map[string]any{"const": "run", "description": "Execute a shell command"},
|
||||
"command": map[string]any{"type": "string", "description": "Shell command to execute"},
|
||||
"background": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Run in background immediately",
|
||||
},
|
||||
"pty": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Run in a pseudo-terminal (PTY) when available",
|
||||
},
|
||||
"cwd": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Working directory for the command",
|
||||
},
|
||||
"timeout": map[string]any{
|
||||
"type": "integer",
|
||||
"description": "Timeout in seconds (default: 0 = no timeout, kills process on expiry)",
|
||||
},
|
||||
},
|
||||
"required": []string{"action", "command"},
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"command": map[string]any{
|
||||
"type": "string",
|
||||
"description": "The shell command to execute",
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"action": map[string]any{"const": "list", "description": "List all active sessions"},
|
||||
},
|
||||
"required": []string{"action"},
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"action": map[string]any{
|
||||
"const": "poll",
|
||||
"description": "Check session status. Returns: {sessionId, status: running|done, exitCode}. exitCode only meaningful when status=done",
|
||||
},
|
||||
"sessionId": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Session ID returned from background command",
|
||||
},
|
||||
},
|
||||
"required": []string{"action", "sessionId"},
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"action": map[string]any{
|
||||
"const": "read",
|
||||
"description": "Read output from session. Returns: {sessionId, output, status: running|done}",
|
||||
},
|
||||
"sessionId": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Session ID returned from background command",
|
||||
},
|
||||
},
|
||||
"required": []string{"action", "sessionId"},
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"action": map[string]any{
|
||||
"const": "write",
|
||||
"description": "Send input to session stdin (only when status=running)",
|
||||
},
|
||||
"sessionId": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Session ID returned from background command",
|
||||
},
|
||||
"data": map[string]any{"type": "string", "description": "Data to write to session stdin."},
|
||||
},
|
||||
"required": []string{"action", "sessionId", "data"},
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"action": map[string]any{"const": "kill", "description": "Terminate session"},
|
||||
"sessionId": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Session ID returned from background command",
|
||||
},
|
||||
},
|
||||
"required": []string{"action", "sessionId"},
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"action": map[string]any{
|
||||
"const": "send-keys",
|
||||
"description": "Send special keys to PTY session. Keys: down/up/left/right/enter/escape/tab/backspace/ctrl-c/ctrl-d/ctrl-z. Multiple keys separated by comma",
|
||||
},
|
||||
"sessionId": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Session ID returned from background command",
|
||||
},
|
||||
"keys": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Comma-separated key names (optional spaces around comma). Valid keys: up, down, left, right, enter, tab, escape, backspace, ctrl-c, ctrl-d, home, end, pageup, pagedown, f1-f12.",
|
||||
},
|
||||
},
|
||||
"required": []string{"action", "sessionId", "keys"},
|
||||
"working_dir": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Optional working directory for the command",
|
||||
},
|
||||
},
|
||||
"required": []string{"command"},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
|
||||
action, _ := args["action"].(string)
|
||||
if action == "" {
|
||||
return ErrorResult("action is required")
|
||||
}
|
||||
|
||||
switch action {
|
||||
case "run":
|
||||
return t.executeRun(ctx, args)
|
||||
case "list":
|
||||
return t.executeList()
|
||||
case "poll":
|
||||
return t.executePoll(args)
|
||||
case "read":
|
||||
return t.executeRead(args)
|
||||
case "write":
|
||||
return t.executeWrite(args)
|
||||
case "kill":
|
||||
return t.executeKill(args)
|
||||
case "send-keys":
|
||||
return t.executeSendKeys(args)
|
||||
default:
|
||||
return ErrorResult(fmt.Sprintf("unknown action: %s", action))
|
||||
}
|
||||
}
|
||||
|
||||
func (t *ExecTool) executeRun(ctx context.Context, args map[string]any) *ToolResult {
|
||||
command, ok := args["command"].(string)
|
||||
if !ok {
|
||||
return ErrorResult("command is required")
|
||||
|
|
@ -344,26 +206,8 @@ func (t *ExecTool) executeRun(ctx context.Context, args map[string]any) *ToolRes
|
|||
}
|
||||
}
|
||||
|
||||
getBoolArg := func(key string) bool {
|
||||
switch v := args[key].(type) {
|
||||
case bool:
|
||||
return v
|
||||
case string:
|
||||
return v == "true"
|
||||
}
|
||||
return false
|
||||
}
|
||||
isPty := getBoolArg("pty")
|
||||
isBackground := getBoolArg("background")
|
||||
|
||||
if isPty {
|
||||
if runtime.GOOS == "windows" {
|
||||
return ErrorResult("PTY is not supported on Windows. Use background=true without pty.")
|
||||
}
|
||||
}
|
||||
|
||||
cwd := t.workingDir
|
||||
if wd, ok := args["cwd"].(string); ok && wd != "" {
|
||||
if wd, ok := args["working_dir"].(string); ok && wd != "" {
|
||||
if t.restrictToWorkspace && t.workingDir != "" {
|
||||
resolvedWD, err := validatePathWithAllowPaths(wd, t.workingDir, true, t.allowedPathPatterns)
|
||||
if err != nil {
|
||||
|
|
@ -409,14 +253,6 @@ func (t *ExecTool) executeRun(ctx context.Context, args map[string]any) *ToolRes
|
|||
}
|
||||
}
|
||||
|
||||
if isBackground {
|
||||
return t.runBackground(ctx, command, cwd, isPty)
|
||||
}
|
||||
|
||||
return t.runSync(ctx, command, cwd)
|
||||
}
|
||||
|
||||
func (t *ExecTool) runSync(ctx context.Context, command, cwd string) *ToolResult {
|
||||
// timeout == 0 means no timeout
|
||||
var cmdCtx context.Context
|
||||
var cancel context.CancelFunc
|
||||
|
|
@ -525,560 +361,6 @@ func (t *ExecTool) runSync(ctx context.Context, command, cwd string) *ToolResult
|
|||
}
|
||||
}
|
||||
|
||||
func (t *ExecTool) runBackground(ctx context.Context, command, cwd string, ptyEnabled bool) *ToolResult {
|
||||
sessionID := generateSessionID()
|
||||
session := &ProcessSession{
|
||||
ID: sessionID,
|
||||
Command: command,
|
||||
PTY: ptyEnabled,
|
||||
Background: true,
|
||||
StartTime: time.Now().Unix(),
|
||||
Status: "running",
|
||||
ptyKeyMode: PtyKeyModeCSI,
|
||||
}
|
||||
|
||||
var cmd *exec.Cmd
|
||||
if runtime.GOOS == "windows" {
|
||||
cmd = exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", command)
|
||||
} else {
|
||||
cmd = exec.Command("sh", "-c", command)
|
||||
}
|
||||
if cwd != "" {
|
||||
cmd.Dir = cwd
|
||||
}
|
||||
|
||||
prepareCommandForTermination(cmd)
|
||||
|
||||
var stdoutReader io.ReadCloser
|
||||
var stderrReader io.ReadCloser
|
||||
var stdinWriter io.WriteCloser
|
||||
|
||||
if ptyEnabled {
|
||||
ptmx, tty, err := pty.Open()
|
||||
if err != nil {
|
||||
return ErrorResult(fmt.Sprintf("failed to create PTY: %v", err))
|
||||
}
|
||||
|
||||
cmd.Stdin = tty
|
||||
cmd.Stdout = tty
|
||||
cmd.Stderr = tty
|
||||
|
||||
// For PTY, we need Setsid to create a new session.
|
||||
// Note: Setsid and Setpgid conflict, so we must replace SysProcAttr entirely.
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
|
||||
|
||||
session.ptyMaster = ptmx
|
||||
} else {
|
||||
var err error
|
||||
stdoutReader, err = cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return ErrorResult(fmt.Sprintf("failed to create stdout pipe: %v", err))
|
||||
}
|
||||
stderrReader, err = cmd.StderrPipe()
|
||||
if err != nil {
|
||||
return ErrorResult(fmt.Sprintf("failed to create stderr pipe: %v", err))
|
||||
}
|
||||
stdinWriter, err = cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return ErrorResult(fmt.Sprintf("failed to create stdin pipe: %v", err))
|
||||
}
|
||||
session.stdoutPipe = io.MultiReader(stdoutReader, stderrReader)
|
||||
session.stdinWriter = stdinWriter
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
if session.ptyMaster != nil {
|
||||
session.ptyMaster.Close()
|
||||
}
|
||||
return ErrorResult(fmt.Sprintf("failed to start command: %v", err))
|
||||
}
|
||||
|
||||
session.PID = cmd.Process.Pid
|
||||
t.sessionManager.Add(session)
|
||||
|
||||
session.outputBuffer = &bytes.Buffer{}
|
||||
|
||||
// PTY mode: read from ptyMaster and wait for process
|
||||
// Note: On Linux, closing ptyMaster doesn't interrupt blocking Read() calls,
|
||||
// so we need cmd.Wait() in a separate goroutine to detect process exit.
|
||||
if session.PTY && session.ptyMaster != nil {
|
||||
go func() {
|
||||
cmd.Wait() // Wait for process to exit
|
||||
session.mu.Lock()
|
||||
if cmd.ProcessState != nil {
|
||||
session.ExitCode = cmd.ProcessState.ExitCode()
|
||||
}
|
||||
session.Status = "done"
|
||||
session.mu.Unlock()
|
||||
}()
|
||||
|
||||
go func() {
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
n, err := session.ptyMaster.Read(buf)
|
||||
if n > 0 {
|
||||
raw := string(buf[:n])
|
||||
if mode := detectPtyKeyMode(raw); mode != PtyKeyModeNotFound && mode != session.GetPtyKeyMode() {
|
||||
session.SetPtyKeyMode(mode)
|
||||
}
|
||||
|
||||
session.mu.Lock()
|
||||
if session.outputBuffer.Len() >= maxOutputBufferSize {
|
||||
if !session.outputTruncated {
|
||||
session.outputBuffer.WriteString(outputTruncateMarker)
|
||||
session.outputTruncated = true
|
||||
}
|
||||
} else {
|
||||
session.outputBuffer.Write(buf[:n])
|
||||
}
|
||||
session.mu.Unlock()
|
||||
}
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
}()
|
||||
} else {
|
||||
// Non-PTY mode: single goroutine reads pipes.
|
||||
// When Read() returns EOF (pipe closed), we break.
|
||||
// When process exits, OS closes pipe write end → Read() returns EOF → we exit.
|
||||
go func() {
|
||||
buf := make([]byte, 4096)
|
||||
|
||||
// Read stdout
|
||||
for {
|
||||
n, err := stdoutReader.Read(buf)
|
||||
if n > 0 {
|
||||
session.mu.Lock()
|
||||
if session.outputBuffer.Len() >= maxOutputBufferSize {
|
||||
if !session.outputTruncated {
|
||||
session.outputBuffer.WriteString(outputTruncateMarker)
|
||||
session.outputTruncated = true
|
||||
}
|
||||
} else {
|
||||
session.outputBuffer.Write(buf[:n])
|
||||
}
|
||||
session.mu.Unlock()
|
||||
}
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Read stderr
|
||||
for {
|
||||
n, err := stderrReader.Read(buf)
|
||||
if n > 0 {
|
||||
session.mu.Lock()
|
||||
if session.outputBuffer.Len() >= maxOutputBufferSize {
|
||||
if !session.outputTruncated {
|
||||
session.outputBuffer.WriteString(outputTruncateMarker)
|
||||
session.outputTruncated = true
|
||||
}
|
||||
} else {
|
||||
session.outputBuffer.Write(buf[:n])
|
||||
}
|
||||
session.mu.Unlock()
|
||||
}
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// All pipes closed, get exit status
|
||||
if stdinWriter != nil {
|
||||
stdinWriter.Close()
|
||||
}
|
||||
cmd.Wait()
|
||||
|
||||
session.mu.Lock()
|
||||
if cmd.ProcessState != nil {
|
||||
session.ExitCode = cmd.ProcessState.ExitCode()
|
||||
}
|
||||
session.Status = "done"
|
||||
session.mu.Unlock()
|
||||
}()
|
||||
}
|
||||
|
||||
resp := ExecResponse{
|
||||
SessionID: sessionID,
|
||||
Status: "running",
|
||||
}
|
||||
data, _ := json.Marshal(resp)
|
||||
return &ToolResult{
|
||||
ForLLM: string(data),
|
||||
ForUser: fmt.Sprintf("Session %s started", sessionID),
|
||||
IsError: false,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *ExecTool) executeList() *ToolResult {
|
||||
sessions := t.sessionManager.List()
|
||||
resp := ExecResponse{
|
||||
Sessions: sessions,
|
||||
}
|
||||
data, _ := json.Marshal(resp)
|
||||
return &ToolResult{
|
||||
ForLLM: string(data),
|
||||
ForUser: fmt.Sprintf("%d active sessions", len(sessions)),
|
||||
IsError: false,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *ExecTool) executePoll(args map[string]any) *ToolResult {
|
||||
sessionID, ok := args["sessionId"].(string)
|
||||
if !ok {
|
||||
return ErrorResult("sessionId is required")
|
||||
}
|
||||
|
||||
session, err := t.sessionManager.Get(sessionID)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrSessionNotFound) {
|
||||
return ErrorResult(fmt.Sprintf("session not found: %s", sessionID))
|
||||
}
|
||||
return ErrorResult(err.Error())
|
||||
}
|
||||
|
||||
resp := ExecResponse{
|
||||
SessionID: sessionID,
|
||||
Status: session.GetStatus(),
|
||||
ExitCode: session.GetExitCode(),
|
||||
}
|
||||
data, _ := json.Marshal(resp)
|
||||
return &ToolResult{
|
||||
ForLLM: string(data),
|
||||
IsError: false,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *ExecTool) executeRead(args map[string]any) *ToolResult {
|
||||
sessionID, ok := args["sessionId"].(string)
|
||||
if !ok {
|
||||
return ErrorResult("sessionId is required")
|
||||
}
|
||||
|
||||
session, err := t.sessionManager.Get(sessionID)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrSessionNotFound) {
|
||||
return ErrorResult(fmt.Sprintf("session not found: %s", sessionID))
|
||||
}
|
||||
return ErrorResult(err.Error())
|
||||
}
|
||||
|
||||
output := session.Read()
|
||||
|
||||
resp := ExecResponse{
|
||||
SessionID: sessionID,
|
||||
Output: output,
|
||||
Status: session.GetStatus(),
|
||||
}
|
||||
data, _ := json.Marshal(resp)
|
||||
return &ToolResult{
|
||||
ForLLM: string(data),
|
||||
IsError: false,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *ExecTool) executeWrite(args map[string]any) *ToolResult {
|
||||
sessionID, ok := args["sessionId"].(string)
|
||||
if !ok {
|
||||
return ErrorResult("sessionId is required")
|
||||
}
|
||||
|
||||
data, ok := args["data"].(string)
|
||||
if !ok {
|
||||
return ErrorResult("data is required")
|
||||
}
|
||||
|
||||
session, err := t.sessionManager.Get(sessionID)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrSessionNotFound) {
|
||||
return ErrorResult(fmt.Sprintf("session not found: %s", sessionID))
|
||||
}
|
||||
return ErrorResult(err.Error())
|
||||
}
|
||||
|
||||
if session.IsDone() {
|
||||
return ErrorResult(fmt.Sprintf("process already exited with code %d", session.GetExitCode()))
|
||||
}
|
||||
|
||||
if err := session.Write(data); err != nil {
|
||||
if errors.Is(err, ErrSessionDone) {
|
||||
return ErrorResult(fmt.Sprintf("process already exited with code %d", session.GetExitCode()))
|
||||
}
|
||||
return ErrorResult(fmt.Sprintf("failed to write to session: %v", err))
|
||||
}
|
||||
|
||||
resp := ExecResponse{
|
||||
SessionID: sessionID,
|
||||
Status: session.GetStatus(),
|
||||
}
|
||||
respData, _ := json.Marshal(resp)
|
||||
return &ToolResult{
|
||||
ForLLM: string(respData),
|
||||
IsError: false,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *ExecTool) executeKill(args map[string]any) *ToolResult {
|
||||
sessionID, ok := args["sessionId"].(string)
|
||||
if !ok {
|
||||
return ErrorResult("sessionId is required")
|
||||
}
|
||||
|
||||
session, err := t.sessionManager.Get(sessionID)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrSessionNotFound) {
|
||||
return ErrorResult(fmt.Sprintf("session not found: %s", sessionID))
|
||||
}
|
||||
return ErrorResult(err.Error())
|
||||
}
|
||||
|
||||
if session.IsDone() {
|
||||
return ErrorResult(fmt.Sprintf("process already exited with code %d", session.GetExitCode()))
|
||||
}
|
||||
|
||||
if err := session.Kill(); err != nil {
|
||||
return ErrorResult(fmt.Sprintf("failed to kill session: %v", err))
|
||||
}
|
||||
|
||||
t.sessionManager.Remove(sessionID)
|
||||
|
||||
resp := ExecResponse{
|
||||
SessionID: sessionID,
|
||||
Status: "done",
|
||||
}
|
||||
data, _ := json.Marshal(resp)
|
||||
return &ToolResult{
|
||||
ForLLM: string(data),
|
||||
ForUser: fmt.Sprintf("Session %s killed", sessionID),
|
||||
IsError: false,
|
||||
}
|
||||
}
|
||||
|
||||
// keyMap maps key names to their escape sequences.
|
||||
var keyMap = map[string]string{
|
||||
"enter": "\r",
|
||||
"return": "\r",
|
||||
"tab": "\t",
|
||||
"escape": "\x1b",
|
||||
"esc": "\x1b",
|
||||
"space": " ",
|
||||
"backspace": "\x7f",
|
||||
"bspace": "\x7f",
|
||||
"up": "\x1b[A",
|
||||
"down": "\x1b[B",
|
||||
"right": "\x1b[C",
|
||||
"left": "\x1b[D",
|
||||
"home": "\x1b[1~",
|
||||
"end": "\x1b[4~",
|
||||
"pageup": "\x1b[5~",
|
||||
"pagedown": "\x1b[6~",
|
||||
"pgup": "\x1b[5~",
|
||||
"pgdn": "\x1b[6~",
|
||||
"insert": "\x1b[2~",
|
||||
"ic": "\x1b[2~",
|
||||
"delete": "\x1b[3~",
|
||||
"del": "\x1b[3~",
|
||||
"dc": "\x1b[3~",
|
||||
"btab": "\x1b[Z",
|
||||
"f1": "\x1bOP",
|
||||
"f2": "\x1bOQ",
|
||||
"f3": "\x1bOR",
|
||||
"f4": "\x1bOS",
|
||||
"f5": "\x1b[15~",
|
||||
"f6": "\x1b[17~",
|
||||
"f7": "\x1b[18~",
|
||||
"f8": "\x1b[19~",
|
||||
"f9": "\x1b[20~",
|
||||
"f10": "\x1b[21~",
|
||||
"f11": "\x1b[23~",
|
||||
"f12": "\x1b[24~",
|
||||
}
|
||||
|
||||
// ss3KeysMap maps key names to SS3 escape sequences
|
||||
var ss3KeysMap = map[string]string{
|
||||
"up": "\x1bOA",
|
||||
"down": "\x1bOB",
|
||||
"right": "\x1bOC",
|
||||
"left": "\x1bOD",
|
||||
"home": "\x1bOH",
|
||||
"end": "\x1bOF",
|
||||
}
|
||||
|
||||
func detectPtyKeyMode(raw string) PtyKeyMode {
|
||||
const SMKX = "\x1b[?1h"
|
||||
const RMKX = "\x1b[?1l"
|
||||
|
||||
lastSmkx := strings.LastIndex(raw, SMKX)
|
||||
lastRmkx := strings.LastIndex(raw, RMKX)
|
||||
|
||||
if lastSmkx == -1 && lastRmkx == -1 {
|
||||
return PtyKeyModeNotFound
|
||||
}
|
||||
|
||||
if lastSmkx > lastRmkx {
|
||||
return PtyKeyModeSS3
|
||||
}
|
||||
return PtyKeyModeCSI
|
||||
}
|
||||
|
||||
// encodeKeyToken encodes a single key token into its escape sequence.
|
||||
// Supports:
|
||||
// - Named keys: "enter", "tab", "up", "ctrl-c", "alt-x", etc.
|
||||
// - Ctrl modifier: "ctrl-c" or "c-c" (sends Ctrl+char)
|
||||
// - Alt modifier: "alt-x" or "m-x" (sends ESC+char)
|
||||
func encodeKeyToken(token string, ptyKeyMode PtyKeyMode) (string, error) {
|
||||
token = strings.ToLower(strings.TrimSpace(token))
|
||||
if token == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// Handle ctrl-X format (c-x)
|
||||
if strings.HasPrefix(token, "c-") {
|
||||
char := token[2]
|
||||
if char >= 'a' && char <= 'z' {
|
||||
return string(rune(char) & 0x1f), nil // ctrl-a through ctrl-z
|
||||
}
|
||||
return "", fmt.Errorf("invalid ctrl key: %s", token)
|
||||
}
|
||||
|
||||
// Handle ctrl-X format (ctrl-x)
|
||||
if strings.HasPrefix(token, "ctrl-") {
|
||||
char := token[5]
|
||||
if char >= 'a' && char <= 'z' {
|
||||
return string(rune(char) & 0x1f), nil
|
||||
}
|
||||
return "", fmt.Errorf("invalid ctrl key: %s", token)
|
||||
}
|
||||
|
||||
// Handle alt-X format (m-x or alt-x)
|
||||
if strings.HasPrefix(token, "m-") || strings.HasPrefix(token, "alt-") {
|
||||
var char string
|
||||
if strings.HasPrefix(token, "m-") {
|
||||
char = token[2:]
|
||||
} else {
|
||||
char = token[4:]
|
||||
}
|
||||
if len(char) == 1 {
|
||||
return "\x1b" + char, nil
|
||||
}
|
||||
return "", fmt.Errorf("invalid alt key: %s", token)
|
||||
}
|
||||
|
||||
// Handle shift modifier for special keys (shift-up, shift-down, etc.)
|
||||
if strings.HasPrefix(token, "s-") || strings.HasPrefix(token, "shift-") {
|
||||
var key string
|
||||
if strings.HasPrefix(token, "s-") {
|
||||
key = token[2:]
|
||||
} else {
|
||||
key = token[6:]
|
||||
}
|
||||
// Apply shift modifier: for single-char keys, return uppercase
|
||||
if seq, ok := keyMap[key]; ok {
|
||||
// For escape sequences, we can't easily add shift
|
||||
// For single-char keys (letters), return uppercase
|
||||
if len(seq) == 1 {
|
||||
return strings.ToUpper(seq), nil
|
||||
}
|
||||
return seq, nil
|
||||
}
|
||||
return "", fmt.Errorf("unknown key with shift: %s", key)
|
||||
}
|
||||
|
||||
if ptyKeyMode == PtyKeyModeSS3 {
|
||||
if seq, ok := ss3KeysMap[token]; ok {
|
||||
return seq, nil
|
||||
}
|
||||
}
|
||||
|
||||
if seq, ok := keyMap[token]; ok {
|
||||
return seq, nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("unknown key: %s (use write action for text input)", token)
|
||||
}
|
||||
|
||||
// encodeKeySequence encodes a slice of key tokens into a single string.
|
||||
func encodeKeySequence(tokens []string, ptyKeyMode PtyKeyMode) (string, error) {
|
||||
var result string
|
||||
for _, token := range tokens {
|
||||
seq, err := encodeKeyToken(token, ptyKeyMode)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
result += seq
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (t *ExecTool) executeSendKeys(args map[string]any) *ToolResult {
|
||||
sessionID, ok := args["sessionId"].(string)
|
||||
if !ok {
|
||||
return ErrorResult("sessionId is required")
|
||||
}
|
||||
|
||||
keysStr, ok := args["keys"].(string)
|
||||
if !ok {
|
||||
return ErrorResult("keys must be a string")
|
||||
}
|
||||
|
||||
if keysStr == "" {
|
||||
return ErrorResult("keys cannot be empty")
|
||||
}
|
||||
|
||||
// Parse comma-separated key names
|
||||
keyNames := strings.Split(keysStr, ",")
|
||||
var keys []string
|
||||
for _, k := range keyNames {
|
||||
k = strings.TrimSpace(k)
|
||||
if k != "" {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
}
|
||||
|
||||
if len(keys) == 0 {
|
||||
return ErrorResult("keys cannot be empty")
|
||||
}
|
||||
|
||||
session, err := t.sessionManager.Get(sessionID)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrSessionNotFound) {
|
||||
return ErrorResult(fmt.Sprintf("session not found: %s", sessionID))
|
||||
}
|
||||
return ErrorResult(err.Error())
|
||||
}
|
||||
|
||||
ptyKeyMode := session.GetPtyKeyMode()
|
||||
|
||||
data, err := encodeKeySequence(keys, ptyKeyMode)
|
||||
if err != nil {
|
||||
return ErrorResult(fmt.Sprintf("invalid key: %v", err))
|
||||
}
|
||||
|
||||
if session.IsDone() {
|
||||
return ErrorResult(fmt.Sprintf("process already exited with code %d", session.GetExitCode()))
|
||||
}
|
||||
|
||||
if err := session.Write(data); err != nil {
|
||||
if errors.Is(err, ErrSessionDone) {
|
||||
return ErrorResult(fmt.Sprintf("process already exited with code %d", session.GetExitCode()))
|
||||
}
|
||||
return ErrorResult(fmt.Sprintf("failed to send keys: %v", err))
|
||||
}
|
||||
|
||||
resp := ExecResponse{
|
||||
SessionID: sessionID,
|
||||
Status: "running",
|
||||
Output: fmt.Sprintf("Sent keys: %v", keys),
|
||||
}
|
||||
respData, _ := json.Marshal(resp)
|
||||
return &ToolResult{
|
||||
ForLLM: string(respData),
|
||||
IsError: false,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *ExecTool) guardCommand(command, cwd string) string {
|
||||
cmd := strings.TrimSpace(command)
|
||||
lower := strings.ToLower(cmd)
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -30,7 +30,6 @@ func TestShellTool_TimeoutKillsChildProcess(t *testing.T) {
|
|||
tool.SetTimeout(500 * time.Millisecond)
|
||||
|
||||
args := map[string]any{
|
||||
"action": "run",
|
||||
// Spawn a child process that would outlive the shell unless process-group kill is used.
|
||||
"command": "sleep 60 & echo $! > child.pid; wait",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,24 +56,3 @@ type ToolFunctionDefinition struct {
|
|||
Description string `json:"description"`
|
||||
Parameters map[string]any `json:"parameters"`
|
||||
}
|
||||
|
||||
type ExecRequest struct {
|
||||
Action string `json:"action"`
|
||||
Command string `json:"command,omitempty"`
|
||||
PTY bool `json:"pty,omitempty"`
|
||||
Background bool `json:"background,omitempty"`
|
||||
Timeout int `json:"timeout,omitempty"`
|
||||
Env map[string]string `json:"env,omitempty"`
|
||||
Cwd string `json:"cwd,omitempty"`
|
||||
SessionID string `json:"sessionId,omitempty"`
|
||||
Data string `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
type ExecResponse struct {
|
||||
SessionID string `json:"sessionId,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
ExitCode int `json:"exitCode,omitempty"`
|
||||
Output string `json:"output,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Sessions []SessionInfo `json:"sessions,omitempty"`
|
||||
}
|
||||
|
|
|
|||
129
workspace/skills/agent-browser/SKILL.md
Normal file
129
workspace/skills/agent-browser/SKILL.md
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
---
|
||||
name: agent-browser
|
||||
description: "Browser automation via agent-browser CLI. Use when the user needs to navigate websites, fill forms, click buttons, take screenshots, extract data, or test web apps."
|
||||
metadata: {"nanobot":{"emoji":"🌐","requires":{"bins":["agent-browser"]},"install":[{"id":"npm","kind":"npm","package":"agent-browser","global":true,"bins":["agent-browser"],"label":"Install agent-browser (npm)"}]}}
|
||||
---
|
||||
|
||||
# Agent Browser
|
||||
|
||||
CLI browser automation via Chrome/Chromium CDP. Install: `npm i -g agent-browser && agent-browser install`.
|
||||
|
||||
**Before using this skill**, verify the tool is available by running `which agent-browser`. If the command is not found, tell the user that browser automation requires the `agent-browser` CLI and Chromium, which are only available in the heavy container image. Do not attempt to install it at runtime.
|
||||
|
||||
## Core Workflow
|
||||
|
||||
1. `agent-browser open <url>` — navigate
|
||||
2. `agent-browser snapshot -i` — get interactive elements with refs (`@e1`, `@e2`, ...)
|
||||
3. Interact using refs — `click @e1`, `fill @e2 "text"`
|
||||
4. Re-snapshot after any navigation or DOM change — refs are invalidated
|
||||
|
||||
```bash
|
||||
agent-browser open https://example.com/form
|
||||
agent-browser snapshot -i
|
||||
# @e1 [input] "Email", @e2 [input] "Password", @e3 [button] "Submit"
|
||||
agent-browser fill @e1 "user@example.com"
|
||||
agent-browser fill @e2 "secret"
|
||||
agent-browser click @e3
|
||||
agent-browser wait --load networkidle
|
||||
agent-browser snapshot -i
|
||||
```
|
||||
|
||||
Chain commands with `&&` when you don't need intermediate output:
|
||||
```bash
|
||||
agent-browser open https://example.com && agent-browser wait --load networkidle && agent-browser snapshot -i
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Navigation
|
||||
agent-browser open <url>
|
||||
agent-browser close
|
||||
|
||||
# Snapshot
|
||||
agent-browser snapshot -i # Interactive elements with refs
|
||||
agent-browser snapshot -s "#selector" # Scope to CSS selector
|
||||
|
||||
# Interaction (use @refs from snapshot)
|
||||
agent-browser click @e1
|
||||
agent-browser fill @e2 "text" # Clear + type
|
||||
agent-browser type @e2 "text" # Type without clearing
|
||||
agent-browser select @e1 "option"
|
||||
agent-browser check @e1
|
||||
agent-browser press Enter
|
||||
agent-browser scroll down 500
|
||||
|
||||
# Get info
|
||||
agent-browser get text @e1
|
||||
agent-browser get url
|
||||
agent-browser get title
|
||||
|
||||
# Wait
|
||||
agent-browser wait @e1 # Wait for element
|
||||
agent-browser wait --load networkidle # Wait for network idle
|
||||
agent-browser wait --url "**/dashboard" # Wait for URL pattern
|
||||
agent-browser wait --text "Welcome" # Wait for text
|
||||
agent-browser wait 2000 # Wait ms
|
||||
|
||||
# Capture
|
||||
agent-browser screenshot # Screenshot to temp dir
|
||||
agent-browser screenshot --full # Full page
|
||||
agent-browser screenshot --annotate # With numbered element labels ([N] -> @eN)
|
||||
agent-browser pdf output.pdf
|
||||
|
||||
# Semantic locators (when refs unavailable)
|
||||
agent-browser find text "Sign In" click
|
||||
agent-browser find label "Email" fill "user@test.com"
|
||||
agent-browser find role button click --name "Submit"
|
||||
```
|
||||
|
||||
## Authentication
|
||||
|
||||
```bash
|
||||
# Option 1: Import from user's running Chrome
|
||||
agent-browser --auto-connect state save ./auth.json
|
||||
agent-browser --state ./auth.json open https://app.example.com
|
||||
|
||||
# Option 2: Persistent profile
|
||||
agent-browser --profile ~/.myapp open https://app.example.com/login
|
||||
# ... login once, all future runs are authenticated
|
||||
|
||||
# Option 3: Session name (auto-save/restore)
|
||||
agent-browser --session-name myapp open https://app.example.com/login
|
||||
# ... login, close, next run state is restored
|
||||
|
||||
# Option 4: State file
|
||||
agent-browser state save auth.json
|
||||
agent-browser state load auth.json
|
||||
```
|
||||
|
||||
## Iframes
|
||||
|
||||
Iframe content is inlined in snapshots. Interact with iframe refs directly — no frame switch needed.
|
||||
|
||||
## Parallel Sessions
|
||||
|
||||
```bash
|
||||
agent-browser --session s1 open https://site-a.com
|
||||
agent-browser --session s2 open https://site-b.com
|
||||
agent-browser session list
|
||||
```
|
||||
|
||||
## JavaScript Eval
|
||||
|
||||
```bash
|
||||
agent-browser eval 'document.title'
|
||||
|
||||
# Complex JS — use --stdin to avoid shell quoting issues
|
||||
agent-browser eval --stdin <<'EVALEOF'
|
||||
JSON.stringify(Array.from(document.querySelectorAll("a")).map(a => a.href))
|
||||
EVALEOF
|
||||
```
|
||||
|
||||
## Cleanup
|
||||
|
||||
Always close sessions when done:
|
||||
```bash
|
||||
agent-browser close
|
||||
agent-browser --session s1 close
|
||||
```
|
||||
Loading…
Add table
Reference in a new issue