@clawhub-zoujiejun-19340163c3
Turn follow-up promises into an execution queue for agents. Use when chats or discussions create tasks that should be claimed and executed during heartbeat,...
---
name: agent-todo
description: Local-first execution queue for OpenClaw agents. Use when an agent should turn promises into executable tasks, pick work during heartbeat, maintain its own queue inside its own workspace, or dispatch work to another registered agent workspace. Triggers: task queue, heartbeat execution, follow-up work, background tasks, multi-agent task routing.
---
# agent-todo Skill
Use this skill as an execution queue, not as a passive reminder list.
核心原则:每个 agent 只维护并消费自己 workspace 下的任务队列;需要跨 agent 分发时,再按 OpenClaw 已注册的 workspace 进行发现和投递。
## Core commands
```bash
bash ./script.sh add "Publish release" \
--task-type publish \
--source "forum:#19/reply:88" \
--next-action "Push main to GitHub and publish ClawHub version" \
--success-criteria "GitHub and ClawHub are both updated"
bash ./script.sh dispatch "Review release" \
--to-agent reviewer \
--task-type review \
--source "chat:direct" \
--next-action "Review release artifacts" \
--success-criteria "Feedback delivered"
bash ./script.sh run-pending --claim
bash ./script.sh done <id> --note "what was completed"
bash ./script.sh report <id>
bash ./script.sh block <id> --reason "why blocked"
bash ./script.sh setup-heartbeat --write
bash ./script.sh setup-heartbeat --all --write
```
## Workflow
1. Add tasks with enough execution context:
- `task_type`
- `next_action`
- `success_criteria`
- `source`
2. For composite goals, prefer `plan` to split them into concrete steps.
3. During heartbeat, run:
- `bash ./script.sh run-pending --claim`
4. If it returns `EXECUTE_NOW`, do the task immediately.
5. Prefer continuing a `running` task before opening a fresh `pending` one.
6. To assign work to another agent, use `dispatch --to-agent <agent_id>`.
7. After execution:
- success → `done`
- generate reply text → `report`
- cannot continue → `block`
- no longer needed → `cancel`
## Storage model
- Current workspace queue: `.agent-todo/tasks.json`
- Optional local identity: `.agent-todo/local.json`
- Workspace discovery source: `~/.openclaw/openclaw.json`
- Heartbeat wiring: managed block in `HEARTBEAT.md`
Do not hand-write workspace paths in normal usage. Let the script resolve the current workspace and discover registered workspaces from OpenClaw.
## Notes
- Single-workspace mode works out of the box after install.
- Multi-agent routing is opt-in: it only matters when you call `dispatch`.
- `setup-heartbeat --all --write` appends or updates a managed block for every discovered workspace instead of overwriting the full file.
- `report` generates different output shapes for forum sources and direct chat sources.
FILE:README.md
# agent-todo
> **Local-first execution queue for OpenClaw agents.**
> Each agent keeps its own queue inside its own workspace, claims work during heartbeat, and can dispatch tasks to another agent when needed.
- ClawHub: https://clawhub.com/skills/agent-todo
- 中文说明: [README.zh-CN.md](./README.zh-CN.md)
## What it solves
Chat is good at promises and bad at follow-through.
`agent-todo` turns “I’ll do this later” into executable work:
- each workspace owns its own queue
- heartbeat picks the next task and claims it
- finished work can be reported back to the original source
- tasks can be dispatched across agents discovered from OpenClaw config
## Storage model
Runtime data lives inside the workspace:
```text
<workspace>/
.agent-todo/
tasks.json
local.json # optional
```
- `tasks.json`: the local execution queue
- `local.json`: optional self-declared identity, e.g. `{"agent_id":"coding","label":"Coding Agent"}`
- workspace discovery source: `~/.openclaw/openclaw.json`
## Quick Start
### Install
```bash
clawhub install agent-todo
cd ~/.openclaw/workspace/skills/agent-todo
chmod +x script.sh todo.sh hooks/*.sh tests/smoke.sh
```
### Initialize local queue
```bash
bash ./script.sh init
```
### Check current status
```bash
bash ./script.sh doctor
```
### Wire heartbeat for current workspace
```bash
bash ./script.sh setup-heartbeat --write
```
### Wire heartbeat for all discovered workspaces
```bash
bash ./script.sh setup-heartbeat --all --write
```
This uses a managed block in `HEARTBEAT.md` and updates that block in place instead of overwriting the whole file. The generated command binds the target workspace explicitly with `AGENT_TODO_WORKSPACE=...`, so shared/external skill installs still read the correct local queue.
### Add a task for the current agent
```bash
bash ./script.sh add "Publish release" \
--task-type publish \
--source "forum:#19/reply:88" \
--next-action "Push GitHub and publish ClawHub" \
--success-criteria "GitHub and ClawHub updated"
```
### Split a composite goal into steps
```bash
bash ./script.sh plan "Open-source release" \
--task-type publish \
--source "chat:direct" \
--steps "Update README; Push GitHub; Publish ClawHub"
```
### Dispatch a task to another agent
```bash
bash ./script.sh dispatch "Review release" \
--to-agent reviewer \
--task-type review \
--source "chat:direct" \
--next-action "Review release artifacts" \
--success-criteria "Feedback delivered"
```
`dispatch` scans workspaces from `~/.openclaw/openclaw.json`, reads each workspace's `.agent-todo/local.json`, and writes the task into the matching target workspace.
### Let heartbeat pick work
```bash
bash ./script.sh run-pending --claim
```
If a task exists, the command prints an `EXECUTE_NOW` brief. If there is no runnable task, it prints `HEARTBEAT_OK`.
### Update task state
```bash
bash ./script.sh block <id> --reason "Need review"
bash ./script.sh done <id> --note "Work completed"
bash ./script.sh report <id>
bash ./script.sh cancel <id> --reason "Handled elsewhere"
```
## Core commands
```bash
bash ./script.sh init
bash ./script.sh doctor
bash ./script.sh setup-heartbeat --write
bash ./script.sh setup-heartbeat --all --write
bash ./script.sh add "Refine release plan" --task-type doc --next-action "Update README and SKILL.md"
bash ./script.sh dispatch "Review release" --to-agent reviewer --task-type review --next-action "Review artifacts"
bash ./script.sh plan "Release" --task-type publish --steps "Update README; Push GitHub; Publish ClawHub"
bash ./script.sh list --status pending
bash ./script.sh show <id>
bash ./script.sh run-pending --claim
bash ./script.sh block <id> --reason "Waiting for review"
bash ./script.sh done <id> --note "README updated and pushed"
bash ./script.sh report <id>
bash ./script.sh cancel <id> --reason "No longer needed"
bash ./script.sh agents list
```
## Statuses
| Status | Meaning |
|---|---|
| pending | queued, not started |
| running | currently being worked on |
| blocked | cannot continue without input or dependency |
| done | completed |
| cancelled | intentionally dropped |
## Testing
```bash
bash tests/smoke.sh
```
## License
MIT License
nse
nse
FILE:README.zh-CN.md
# agent-todo
> **面向 OpenClaw agent 的本地优先执行队列。**
> 每个 agent 在自己的 workspace 里维护自己的任务队列,由 heartbeat 认领执行;需要时再把任务分发给其他 agent。
## 核心设计
- **本地优先**:每个 workspace 自己维护 `.agent-todo/tasks.json`
- **安装即用**:单 workspace 场景下,`clawhub install` 后即可直接使用
- **多 agent 按需开启**:只有调用 `dispatch --to-agent` 时才需要发现其他 workspace
- **OpenClaw 原生发现**:不再维护额外 registry,直接从 `~/.openclaw/openclaw.json` 读取已注册 workspace
- **托管 heartbeat 块**:`setup-heartbeat --all --write` 只追加或更新托管块,不覆盖整份 `HEARTBEAT.md`
## 运行时数据
```text
<workspace>/
.agent-todo/
tasks.json
local.json # 可选,本地身份声明
```
- `tasks.json`:当前 workspace 的任务队列
- `local.json`:可选,例如 `{"agent_id":"coding","label":"Coding Agent"}`
## 快速开始
```bash
bash ./script.sh init
bash ./script.sh doctor
bash ./script.sh setup-heartbeat --write
```
### 添加本地任务
```bash
bash ./script.sh add "修复 forum 通知去重" \
--task-type coding \
--source "chat:direct" \
--next-action "检查未读通知生成逻辑" \
--success-criteria "重复通知不再出现"
```
### 分发给其他 agent
```bash
bash ./script.sh dispatch "复核发版结果" \
--to-agent reviewer \
--task-type review \
--source "chat:direct" \
--next-action "复核 release 产物" \
--success-criteria "完成反馈"
```
### heartbeat 认领任务
```bash
bash ./script.sh run-pending --claim
```
## 常用命令
```bash
bash ./script.sh add ...
bash ./script.sh dispatch --to-agent <agent_id> ...
bash ./script.sh plan ...
bash ./script.sh run-pending --claim
bash ./script.sh done <id> --note "..."
bash ./script.sh block <id> --reason "..."
bash ./script.sh report <id>
bash ./script.sh setup-heartbeat --write
bash ./script.sh setup-heartbeat --all --write
bash ./script.sh agents list
```
```
FILE:SPEC.md
# agent-todo Specification
## Positioning
`agent-todo` is a **local-first execution queue** for OpenClaw agents.
It is not a shared reminder board anymore. Each agent owns its queue inside its own workspace, heartbeat only consumes local work, and cross-agent routing happens explicitly through `dispatch`.
## Design Goals
1. **Install and use immediately**
- single-workspace mode must work after install
- no extra registry required for the default path
2. **Align heartbeat, workspace, and memory**
- each workspace keeps its own queue
- each agent heartbeat only consumes its own queue
- execution context stays inside the same workspace
3. **Support multi-agent routing without heavy central config**
- discover workspaces from `~/.openclaw/openclaw.json`
- read each workspace's `.agent-todo/local.json` when dispatching
4. **Avoid schema drift**
- runtime storage uses JSON instead of Markdown tables
## Runtime Layout
```text
<workspace>/
.agent-todo/
tasks.json
local.json
HEARTBEAT.md
```
### `tasks.json`
```json
{
"version": 2,
"tasks": [
{
"id": "uuid",
"title": "Review unread notification logic",
"status": "pending",
"task_type": "coding",
"source": "chat:direct",
"next_action": "Inspect unread notification generation",
"success_criteria": "No duplicate notifications",
"result": "",
"deadline": "",
"owner_agent_id": "coding",
"created_by_agent_id": "coding",
"claimed_at": "",
"last_attempt_at": "",
"blocked_reason": "",
"parent_id": "",
"depends_on": [],
"created_at": "2026-03-24T00:00:00+08:00",
"updated_at": "2026-03-24T00:00:00+08:00",
"completed_at": "",
"tags": []
}
]
}
```
### `local.json`
Optional self-declared identity:
```json
{
"agent_id": "coding",
"label": "Coding Agent"
}
```
If missing, the runtime falls back to the current workspace entry inside `openclaw.json`; if still unresolved, it uses `local`.
## Workspace Discovery
Use `~/.openclaw/openclaw.json` as the only discovery source for agent workspaces.
### Discovery flow
1. Read `agents.defaults.workspace`
2. Read `agents.list[*].workspace`
3. Deduplicate paths
4. When dispatching, inspect `<workspace>/.agent-todo/local.json`
5. Match `agent_id`
### Why this design
- no duplicate workspace registry
- no drift between OpenClaw and agent-todo
- keeps the model aligned with how OpenClaw already stores agents
## Command Model
### Local commands
- `init`
- `doctor`
- `setup-heartbeat [--write] [--all] [--dry-run]`
- `add`
- `plan`
- `list`
- `show`
- `report`
- `run-pending --claim`
- `done`
- `block`
- `unblock`
- `cancel`
### Cross-agent command
- `dispatch --to-agent <agent_id>`
`add` always writes to the current workspace. `dispatch` always writes to a discovered target workspace.
## Heartbeat Strategy
`setup-heartbeat` manages a block inside `HEARTBEAT.md`:
```md
<!-- agent-todo:begin -->
AGENT_TODO_WORKSPACE=/path/to/workspace bash ./skills/agent-todo/script.sh run-pending --claim
<!-- agent-todo:end -->
```
When the skill lives outside the target workspace, `setup-heartbeat` must bind the target workspace explicitly via `AGENT_TODO_WORKSPACE=...` so `run-pending` reads the correct local queue.
### Rules
- if the block is missing: append it
- if the block exists: update it in place
- never overwrite unrelated user content in `HEARTBEAT.md`
- `--all` applies the same append-or-update logic to every discovered workspace
## Selection Rules for `run-pending`
1. ignore `done`, `cancelled`, `blocked`
2. require all `depends_on` tasks to be done
3. prefer child tasks over standalone/parent tasks
4. prefer `running` over `pending`
5. among pending tasks, prefer earlier deadline, then earlier creation time
## Reporting
Keep source-aware reports:
- `forum:#...` → forum reply format
- `chat:...` → direct-chat reply format
- others → generic report format
## Error Handling
### Dispatch target not found
Fail fast with a clear error.
### Duplicate `agent_id`
Fail fast and show conflicting workspaces.
### Missing local identity
Allow local mode. Only dispatch requires target discovery.
## Non-Goals
- no shared global TODO file
- no central agent registry owned by agent-todo
- no whole-file overwrite of `HEARTBEAT.md`
FILE:_meta.json
{
"name": "agent-todo",
"slug": "agent-todo",
"version": "1.2.1",
"description": "Turn conversational promises into an automated execution queue. Use this skill when: you need to schedule background tasks, \"do something later\" during heartbeats, replace passive reminders with auto-executing tasks, or track multi-step goals with success criteria. Triggers: task queue, background execution, follow-up, heartbeat automation.",
"author": "Agent Todo Contributors",
"license": "MIT",
"homepage": "https://clawhub.com/skills/agent-todo",
"tags": [
"todo",
"queue",
"heartbeat",
"automation",
"workflow"
],
"workspace": "agent-todo"
}
FILE:hooks/heartbeat.sh
#!/usr/bin/env bash
# heartbeat.sh - Hook: pick the next executable task during heartbeat
#
# Integration:
# cd /path/to/agent-todo && ./script.sh run-pending --claim
#
# If there is no pending task, the command prints HEARTBEAT_OK.
# If a task is found, it prints an EXECUTE_NOW brief for the agent to act on.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "BASH_SOURCE[0]")" && pwd)"
SKILL_DIR="$(dirname "$SCRIPT_DIR")"
exec "SKILL_DIR/script.sh" run-pending --claim
FILE:hooks/post_reply.sh
#!/usr/bin/env bash
# post_reply.sh - Hook: convert reply commitments into executable tasks
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "BASH_SOURCE[0]")" && pwd)"
SKILL_DIR="$(dirname "$SCRIPT_DIR")"
REPLY_CONTENT="-${1:-}"
FORUM_TOPIC_ID="-"
REPLY_ID="-"
REPLY_AUTHOR="-unknown"
REPLY_AUTHOR_AGENT_ID="-"
if [[ -z "$REPLY_CONTENT" ]]; then
echo "post_reply hook: no content provided, skipping"
exit 0
fi
TODO_PATTERNS=(
"我会"
"我来"
"负责"
"后续我来"
"稍后我来"
"我去处理"
"我去做"
"今天我来"
"明天我来"
"TODO"
"\[TODO\]"
)
matched_pattern=""
for pattern in "TODO_PATTERNS[@]"; do
if echo "$REPLY_CONTENT" | grep -iqE "$pattern"; then
matched_pattern="$pattern"
break
fi
done
if [[ -z "$matched_pattern" ]]; then
echo "post_reply hook: no executable commitment detected, skipping"
exit 0
fi
infer_task_type() {
local text="$1"
if echo "$text" | grep -qiE '代码|修复|实现|开发|重构|脚本|bug|接口|deploy|发布'; then
echo "coding"
elif echo "$text" | grep -qiE '文档|README|说明|整理|总结|方案|spec|设计'; then
echo "doc"
elif echo "$text" | grep -qiE '查|调研|搜索|research|分析|排查'; then
echo "research"
elif echo "$text" | grep -qiE '回复|回帖|同步|汇报|通知'; then
echo "reply"
elif echo "$text" | grep -qiE 'review|审查|review comment|检查'; then
echo "review"
elif echo "$text" | grep -qiE '发布|发版|push|tag|clawhub|github'; then
echo "publish"
else
echo "general"
fi
}
infer_deadline() {
local text="$1"
if echo "$text" | grep -q '今天\|今晚'; then
echo '今天 23:59'
elif echo "$text" | grep -q '明天\|明早'; then
echo '明天 18:00'
elif echo "$text" | grep -q '这周\|本周\|周末'; then
echo '本周日 23:59'
else
echo ''
fi
}
extract_title() {
local text="$1"
echo "$text" | sed '/^[[:space:]]*$/d' | head -1 | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | sed 's/^【[^】]*】//' | cut -c1-100
}
build_next_action() {
local title="$1"
local topic_id="$2"
printf 'Complete the promised follow-up: %s. When finished, reply back to forum topic #%s.' "$title" "-0"
}
build_success_criteria() {
local title="$1"
local topic_id="$2"
printf 'The promised work "%s" is completed and a result update is posted back to forum topic #%s.' "$title" "-0"
}
title=$(extract_title "$REPLY_CONTENT")
title="title//|/∙"
title="title//$'\n'/"
task_type=$(infer_task_type "$REPLY_CONTENT")
deadline=$(infer_deadline "$REPLY_CONTENT")
next_action=$(build_next_action "$title" "$FORUM_TOPIC_ID")
success_criteria=$(build_success_criteria "$title" "$FORUM_TOPIC_ID")
source="forum:#-0/reply:-0"
tasks_file="-${SKILL_DIR/../..}/.agent-todo/tasks.json"
if [[ -f "$tasks_file" ]] && grep -Fq "$source" "$tasks_file"; then
echo "post_reply hook: dedup skip - source already queued"
exit 0
fi
echo "post_reply hook: executable commitment detected"
echo " pattern: $matched_pattern"
echo " topic: #-0"
echo " author: REPLY_AUTHOR"
echo " type: task_type"
echo " title: title"
add_args=(
--task-type "$task_type"
--source "$source"
--next-action "$next_action"
--success-criteria "$success_criteria"
)
if [[ -n "$deadline" ]]; then
add_args+=(--deadline "$deadline")
fi
if [[ -n "$REPLY_AUTHOR_AGENT_ID" ]]; then
cmd=(dispatch "$title" --to-agent "$REPLY_AUTHOR_AGENT_ID" "add_args[@]")
else
cmd=(add "$title" "add_args[@]")
fi
if result=$("SKILL_DIR/script.sh" "cmd[@]" 2>&1); then
echo "post_reply hook: task queued successfully"
echo "$result"
else
echo "post_reply hook: task queue failed" >&2
echo "$result" >&2
exit 1
fi
FILE:references/openclaw-hooks.md
# OpenClaw Hooks Integration Guide
This document describes how to integrate `agent-todo` with OpenClaw hooks as an execution queue.
## Hook Scripts
`agent-todo` provides two hook scripts:
- `hooks/post_reply.sh` — convert reply commitments into executable tasks
- `hooks/heartbeat.sh` — claim the next task during heartbeat
## Installation
### 1. Copy hooks into your OpenClaw hooks directory
```bash
mkdir -p ~/.openclaw/hooks/agent-todo
cp -r ./hooks/* ~/.openclaw/hooks/agent-todo/
```
### 2. Enable hooks
If your OpenClaw setup supports a hooks enable command:
```bash
openclaw hooks enable agent-todo
```
Or configure manually:
```toml
[hooks.agent-todo]
enabled = true
path = "~/.openclaw/hooks/agent-todo"
```
## Heartbeat Integration
Add this to `HEARTBEAT.md`:
```bash
bash /path/to/agent-todo/script.sh run-pending --claim
```
Expected behavior:
- no task → `HEARTBEAT_OK`
- task found → `EXECUTE_NOW` brief is printed
- the agent should then do the task immediately
- success → mark with `done`
- blocked → mark with `block`
## Reply Hook Integration
When a reply contains a clear commitment such as “我来处理” or “我会补上”, `post_reply.sh` turns it into a structured queue item.
The generated task includes:
- `task_type`
- `source`
- `next_action`
- `success_criteria`
- inferred deadline when obvious words like 今天 / 明天 / 这周 appear
## HEARTBEAT.md Pattern
```markdown
## Agent execution queue
bash /path/to/agent-todo/script.sh run-pending --claim
```
## Cron Integration (Alternative)
```bash
# Try to claim one task every hour during work hours
0 9-18 * * * cd /path/to/agent-todo && bash ./script.sh run-pending --claim
```
## OpenClaw Cron Integration
```json
{
"name": "agent-todo-run-pending",
"schedule": { "kind": "cron", "expr": "0 * * * *", "tz": "Asia/Shanghai" },
"payload": {
"kind": "agentTurn",
"message": "Run: cd /path/to/agent-todo && bash ./script.sh run-pending --claim"
},
"sessionTarget": "isolated"
}
```
FILE:script.sh
#!/usr/bin/env bash
# script.sh - CLI entry point for agent-todo skill
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "BASH_SOURCE[0]")" && pwd)"
exec "SCRIPT_DIR/todo.sh" "$@"
FILE:scripts/agent_todo.py
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import os
import re
import shlex
import sys
import uuid
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
BLOCK_BEGIN = "<!-- agent-todo:begin -->"
BLOCK_END = "<!-- agent-todo:end -->"
DEFAULT_SUCCESS = "Mark done and report back to source"
DEFAULT_OPENCLAW = Path.home() / ".openclaw" / "openclaw.json"
def now_iso() -> str:
return datetime.now().astimezone().isoformat(timespec="seconds")
def parse_deadline(text: str) -> str:
text = (text or "").strip()
if not text:
return ""
lower = text.lower()
now = datetime.now().astimezone()
if any(x in text for x in ["今天", "今晚"]):
dt = now.replace(hour=23, minute=59, second=0, microsecond=0)
return dt.isoformat(timespec="seconds")
if any(x in text for x in ["明天", "明早"]):
dt = (now + timedelta(days=1)).replace(hour=18, minute=0, second=0, microsecond=0)
return dt.isoformat(timespec="seconds")
if any(x in text for x in ["这周", "本周", "周末"]):
days = (6 - now.weekday()) % 7
dt = (now + timedelta(days=days)).replace(hour=23, minute=59, second=0, microsecond=0)
return dt.isoformat(timespec="seconds")
for fmt in ["%Y-%m-%d %H:%M", "%Y-%m-%d", "%Y/%m/%d %H:%M", "%Y/%m/%d"]:
try:
dt = datetime.strptime(text, fmt)
return dt.replace(tzinfo=now.tzinfo).isoformat(timespec="seconds")
except ValueError:
pass
try:
return datetime.fromisoformat(text).astimezone().isoformat(timespec="seconds")
except ValueError:
return text
def deadline_ts(text: str) -> float:
if not text:
return float("inf")
try:
return datetime.fromisoformat(text).timestamp()
except ValueError:
return float("inf")
def load_json(path: Path, default: Any) -> Any:
if not path.exists():
return default
return json.loads(path.read_text(encoding="utf-8"))
def write_json(path: Path, data: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
tmp.replace(path)
@dataclass
class Runtime:
script_dir: Path
workspace: Path
data_dir: Path
tasks_path: Path
local_path: Path
heartbeat_path: Path
openclaw_path: Path
openclaw: dict[str, Any]
current_agent_id: str
current_agent_label: str
@classmethod
def detect(cls, script_dir: Path) -> "Runtime":
workspace_env = os.environ.get("AGENT_TODO_WORKSPACE")
todo_db_env = os.environ.get("TODO_DB")
if workspace_env:
workspace = Path(workspace_env).expanduser().resolve()
elif todo_db_env:
workspace = Path(todo_db_env).expanduser().resolve().parent
else:
workspace = (script_dir / ".." / "..").resolve()
openclaw_path = Path(os.environ.get("OPENCLAW_CONFIG", str(DEFAULT_OPENCLAW))).expanduser().resolve()
openclaw = load_json(openclaw_path, {}) if openclaw_path.exists() else {}
data_dir = workspace / ".agent-todo"
tasks_path = data_dir / "tasks.json"
local_path = data_dir / "local.json"
heartbeat_path = workspace / "HEARTBEAT.md"
local_cfg = load_json(local_path, {}) if local_path.exists() else {}
agent_id = str(local_cfg.get("agent_id") or "").strip()
label = str(local_cfg.get("label") or "").strip()
for agent in openclaw.get("agents", {}).get("list", []):
if Path(agent.get("workspace", "")).expanduser().resolve() == workspace:
agent_id = agent_id or str(agent.get("id") or agent.get("name") or "local")
identity = agent.get("identity", {}) or {}
label = label or str(identity.get("name") or agent.get("name") or agent_id)
break
if not agent_id:
agent_id = "local"
if not label:
label = agent_id
return cls(
script_dir=script_dir,
workspace=workspace,
data_dir=data_dir,
tasks_path=tasks_path,
local_path=local_path,
heartbeat_path=heartbeat_path,
openclaw_path=openclaw_path,
openclaw=openclaw,
current_agent_id=agent_id,
current_agent_label=label,
)
def init_store(self) -> None:
self.data_dir.mkdir(parents=True, exist_ok=True)
if not self.tasks_path.exists():
write_json(self.tasks_path, {"version": 2, "tasks": []})
def load_tasks(self) -> list[dict[str, Any]]:
self.init_store()
data = load_json(self.tasks_path, {"version": 2, "tasks": []})
return list(data.get("tasks", []))
def save_tasks(self, tasks: list[dict[str, Any]]) -> None:
write_json(self.tasks_path, {"version": 2, "tasks": tasks})
def heartbeat_command(self, workspace: Path | None = None) -> str:
workspace = (workspace or self.workspace).resolve()
script_path = (self.script_dir / "script.sh").resolve()
try:
rel = os.path.relpath(script_path, workspace)
if not rel.startswith(".."):
return f"AGENT_TODO_WORKSPACE={shlex.quote(str(workspace))} bash ./{rel} run-pending --claim"
except ValueError:
pass
return (
f"AGENT_TODO_WORKSPACE={shlex.quote(str(workspace))} "
f"bash {shlex.quote(str(script_path))} run-pending --claim"
)
def discovered_workspaces(self) -> list[Path]:
items: list[Path] = []
defaults_workspace = self.openclaw.get("agents", {}).get("defaults", {}).get("workspace")
if defaults_workspace:
items.append(Path(defaults_workspace).expanduser().resolve())
for agent in self.openclaw.get("agents", {}).get("list", []):
workspace = agent.get("workspace")
if workspace:
items.append(Path(workspace).expanduser().resolve())
items.append(self.workspace)
result: list[Path] = []
seen: set[str] = set()
for item in items:
key = str(item)
if key not in seen:
seen.add(key)
result.append(item)
return result
def find_agent_workspace(self, agent_id: str) -> Path:
matches: list[Path] = []
for workspace in self.discovered_workspaces():
local_path = workspace / ".agent-todo" / "local.json"
if not local_path.exists():
continue
try:
local_cfg = load_json(local_path, {})
except Exception:
continue
if str(local_cfg.get("agent_id") or "").strip() == agent_id:
matches.append(workspace)
if not matches:
raise SystemExit(
f"ERROR: agent '{agent_id}' not found in discovered workspaces from {self.openclaw_path}."
)
if len(matches) > 1:
joined = ", ".join(str(x) for x in matches)
raise SystemExit(f"ERROR: multiple workspaces declare agent_id '{agent_id}': {joined}")
return matches[0]
def ensure_heartbeat_block(path: Path, command: str, write: bool) -> tuple[str, bool]:
block = "\n".join(
[
BLOCK_BEGIN,
"### Agent Todo 队列",
"检查当前 workspace 的 agent-todo 是否有可执行任务:",
f"- 命令: `{command}`",
"- 逻辑: 如果返回 `EXECUTE_NOW`,立即执行该任务;如果返回 `HEARTBEAT_OK`,继续处理其他 heartbeat 项。",
"- 注意: 完成后调用 `done`,受阻则调用 `block`。",
BLOCK_END,
]
)
if path.exists():
content = path.read_text(encoding="utf-8")
else:
content = ""
pattern = re.compile(re.escape(BLOCK_BEGIN) + r".*?" + re.escape(BLOCK_END), re.S)
if pattern.search(content):
new_content = pattern.sub(block, content, count=1)
elif content.strip():
suffix = "\n" if content.endswith("\n") else "\n\n"
new_content = f"{content}{suffix}{block}\n"
else:
new_content = block + "\n"
changed = new_content != content
if write and changed:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(new_content, encoding="utf-8")
return new_content, changed
def format_status(status: str) -> str:
return {
"pending": "⏳ pending",
"running": "🏃 running",
"blocked": "🧱 blocked",
"done": "✅ done",
"cancelled": "❌ cancelled",
}.get(status, status)
def task_by_id(tasks: list[dict[str, Any]], task_id: str) -> dict[str, Any]:
for task in tasks:
if task["id"] == task_id:
return task
raise SystemExit(f"未找到任务: {task_id}")
def render_report(task: dict[str, Any]) -> str:
title = task["title"]
source = task.get("source", "")
completed_at = task.get("completed_at", "")
result = task.get("result", "")
success = task.get("success_criteria", "")
forum = re.match(r"^forum:#(\d+)(/reply:(\d+))?$", source)
if forum:
topic_id = forum.group(1)
lines = [
"【回帖内容】",
f"✅ 已完成:{title}",
f" - 话题:#{topic_id}",
f" - 完成时间:{completed_at}",
]
if result:
lines.append(f" - 结果:{result}")
if success:
lines.append(f" - 对照标准:{success}")
lines.extend(["", "如无遗漏,我这边先收口。"])
return "\n".join(lines)
if source.startswith("chat:"):
lines = ["【回消息内容】", f"✅ 已完成:{title}", f" - 完成时间:{completed_at}"]
if result:
lines.append(f" - 结果:{result}")
if success:
lines.append(f" - 对照标准:{success}")
return "\n".join(lines)
lines = ["【通用汇报】", f"✅ {title}", f" - completed_at: {completed_at}"]
if source:
lines.insert(2, f" - source: {source}")
if result:
lines.append(f" - result: {result}")
if success:
lines.append(f" - success_criteria: {success}")
return "\n".join(lines)
def select_task(tasks: list[dict[str, Any]]) -> dict[str, Any] | None:
valid = [x for x in tasks if x["status"] not in {"done", "cancelled", "blocked"}]
if not valid:
return None
done_ids = {x["id"] for x in tasks if x["status"] == "done"}
candidates: list[dict[str, Any]] = []
for task in valid:
deps = [x for x in task.get("depends_on", []) if x]
if any(dep not in done_ids for dep in deps):
continue
candidates.append(task)
if not candidates:
return None
def sort_key(task: dict[str, Any]) -> tuple[Any, ...]:
pref = 1 if task.get("parent_id") else 2
if task["status"] == "running":
return (pref, 0, task.get("last_attempt_at") or task.get("created_at") or "")
return (pref, 1, deadline_ts(task.get("deadline", "")), task.get("created_at") or "")
candidates.sort(key=sort_key)
return candidates[0]
def new_task(runtime: Runtime, title: str, args: argparse.Namespace, owner_id: str | None = None) -> dict[str, Any]:
ts = now_iso()
owner_id = owner_id or runtime.current_agent_id
return {
"id": str(uuid.uuid4()),
"title": title,
"status": "pending",
"task_type": args.task_type,
"source": args.source or "",
"next_action": args.next_action or title,
"success_criteria": args.success_criteria or DEFAULT_SUCCESS,
"result": "",
"deadline": parse_deadline(args.deadline or ""),
"owner_agent_id": owner_id,
"created_by_agent_id": runtime.current_agent_id,
"claimed_at": "",
"last_attempt_at": "",
"blocked_reason": "",
"parent_id": args.parent_id or "",
"depends_on": [x.strip() for x in (args.depends_on or "").split(",") if x.strip()],
"created_at": ts,
"updated_at": ts,
"completed_at": "",
"tags": [x.strip() for x in (args.tags or "").split(",") if x.strip()],
}
def common_task_parser(parser: argparse.ArgumentParser, include_parent: bool = True) -> None:
parser.add_argument("--task-type", default="general")
parser.add_argument("--deadline", default="")
parser.add_argument("--source", default="")
parser.add_argument("--next-action", default="")
parser.add_argument("--success-criteria", default=DEFAULT_SUCCESS)
parser.add_argument("--tags", default="")
if include_parent:
parser.add_argument("--parent-id", default="")
parser.add_argument("--depends-on", default="")
def cmd_init(runtime: Runtime, _args: argparse.Namespace) -> int:
runtime.init_store()
print(f"✅ agent-todo queue initialized: {runtime.tasks_path}")
return 0
def cmd_doctor(runtime: Runtime, _args: argparse.Namespace) -> int:
runtime.init_store()
print("agent-todo doctor")
print(f"- workspace: {runtime.workspace}")
print(f"- tasks: {runtime.tasks_path}")
print(f"- local config: {runtime.local_path}")
print(f"- openclaw config: {runtime.openclaw_path}")
print(f"- current agent: {runtime.current_agent_id} ({runtime.current_agent_label})")
print(f"- discovered workspaces: {len(runtime.discovered_workspaces())}")
command = runtime.heartbeat_command()
_, changed = ensure_heartbeat_block(runtime.heartbeat_path, command, write=False)
print(f"- heartbeat file: {runtime.heartbeat_path}")
print(f"- heartbeat command: {command}")
print(f"- heartbeat managed block: {'missing ⚠️' if changed else 'configured ✅'}")
return 0
def cmd_setup_heartbeat(runtime: Runtime, args: argparse.Namespace) -> int:
targets = runtime.discovered_workspaces() if args.all else [runtime.workspace]
for workspace in targets:
hb_path = workspace / "HEARTBEAT.md"
command = runtime.heartbeat_command(workspace)
_, changed = ensure_heartbeat_block(hb_path, command, write=args.write)
state = "updated" if changed else "ok"
if args.dry_run and not args.write:
action = "would update" if changed else "already configured"
elif args.write:
action = "updated" if changed else "already configured"
else:
action = "preview"
print(f"- {workspace}: {action} ({state})")
if not args.write:
print("\nUse --write to persist the managed heartbeat block.")
return 0
def cmd_add(runtime: Runtime, args: argparse.Namespace) -> int:
tasks = runtime.load_tasks()
task = new_task(runtime, args.title, args)
tasks.append(task)
runtime.save_tasks(tasks)
print(f"✅ task queued [{task['id']}]")
print(f" title: {task['title']}")
print(f" type: {task['task_type']}")
print(f" owner_agent_id: {task['owner_agent_id']}")
if task["deadline"]:
print(f" deadline: {task['deadline']}")
if task["next_action"]:
print(f" next_action: {task['next_action']}")
return 0
def cmd_dispatch(runtime: Runtime, args: argparse.Namespace) -> int:
target_workspace = runtime.find_agent_workspace(args.to_agent)
target_runtime = Runtime.detect(runtime.script_dir)
target_runtime.workspace = target_workspace
target_runtime.data_dir = target_workspace / ".agent-todo"
target_runtime.tasks_path = target_runtime.data_dir / "tasks.json"
target_runtime.local_path = target_runtime.data_dir / "local.json"
target_runtime.heartbeat_path = target_workspace / "HEARTBEAT.md"
tasks = target_runtime.load_tasks()
task = new_task(runtime, args.title, args, owner_id=args.to_agent)
tasks.append(task)
target_runtime.save_tasks(tasks)
print(f"✅ task dispatched [{task['id']}]")
print(f" to_agent: {args.to_agent}")
print(f" workspace: {target_workspace}")
print(f" title: {task['title']}")
return 0
def cmd_plan(runtime: Runtime, args: argparse.Namespace) -> int:
tasks = runtime.load_tasks()
parent = new_task(runtime, args.title, args)
parent["next_action"] = f"Break down and complete plan: {args.title}"
parent["success_criteria"] = args.success_criteria or "All planned steps are completed and reported back"
tasks.append(parent)
prev = ""
steps = [x.strip() for x in args.steps.split(";") if x.strip()]
if not steps:
raise SystemExit("错误: plan 必须提供可用的 --steps")
print(f"📋 parent plan [{parent['id']}]")
print(f" title: {args.title}")
for idx, step in enumerate(steps, start=1):
child_args = argparse.Namespace(**vars(args))
child_args.parent_id = parent["id"]
child_args.depends_on = prev
child_args.next_action = step
child_args.success_criteria = f"Step {idx}/{len(steps)} completed for plan: {args.title}"
child = new_task(runtime, f"{args.title} / step {idx}: {step}", child_args)
tasks.append(child)
prev = child["id"]
print(f"✅ child {idx} [{child['id']}]")
print(f" title: {child['title']}")
if child["depends_on"]:
print(f" depends_on: {child['depends_on'][0]}")
runtime.save_tasks(tasks)
print(f"📦 plan queued: {args.title}")
print(f" parent: {parent['id']}")
print(f" steps: {len(steps)}")
return 0
def cmd_list(runtime: Runtime, args: argparse.Namespace) -> int:
tasks = runtime.load_tasks()
filtered = []
for task in tasks:
if args.status and task["status"] != args.status:
continue
if args.owner_agent and task.get("owner_agent_id") != args.owner_agent:
continue
filtered.append(task)
print("\n═══════════════════════════════════════")
print(" agent-todo execution queue")
print("═══════════════════════════════════════\n")
if not filtered:
print(" (no matching tasks)\n")
return 0
for task in filtered:
print(f" {format_status(task['status'])} [{task['id'][:8]}]")
print(f" title: {task['title']}")
if task.get("task_type"):
print(f" type: {task['task_type']}")
if task.get("owner_agent_id"):
print(f" owner_agent_id: {task['owner_agent_id']}")
if task.get("deadline"):
print(f" deadline: {task['deadline']}")
if task.get("source"):
print(f" source: {task['source']}")
if task.get("next_action"):
print(f" next_action: {task['next_action']}")
print()
return 0
def cmd_show(runtime: Runtime, args: argparse.Namespace) -> int:
task = task_by_id(runtime.load_tasks(), args.id)
print("\n═══════════════════════════════════════")
print(" task detail")
print("═══════════════════════════════════════")
for key in [
"id",
"title",
"task_type",
"owner_agent_id",
"created_by_agent_id",
"source",
"status",
"deadline",
"next_action",
"success_criteria",
"created_at",
"updated_at",
"last_attempt_at",
"completed_at",
"result",
"parent_id",
"depends_on",
"tags",
]:
print(f" {key:17} {task.get(key, '')}")
print()
return 0
def cmd_report(runtime: Runtime, args: argparse.Namespace) -> int:
print(render_report(task_by_id(runtime.load_tasks(), args.id)))
return 0
def cmd_done(runtime: Runtime, args: argparse.Namespace) -> int:
tasks = runtime.load_tasks()
task = task_by_id(tasks, args.id)
ts = now_iso()
task["status"] = "done"
task["updated_at"] = ts
task["completed_at"] = ts
task["result"] = args.note
runtime.save_tasks(tasks)
print(f"✅ task done [{task['id']}]")
print(f" title: {task['title']}")
print("\n═══ completion report ═══")
print(render_report(task))
return 0
def cmd_block(runtime: Runtime, args: argparse.Namespace) -> int:
tasks = runtime.load_tasks()
task = task_by_id(tasks, args.id)
task["status"] = "blocked"
task["blocked_reason"] = args.reason
task["result"] = args.reason
task["updated_at"] = now_iso()
runtime.save_tasks(tasks)
print(f"🧱 task blocked [{task['id']}]")
if args.reason:
print(f" reason: {args.reason}")
return 0
def cmd_unblock(runtime: Runtime, args: argparse.Namespace) -> int:
tasks = runtime.load_tasks()
task = task_by_id(tasks, args.id)
if task["status"] != "blocked":
print(f"⚠️ 任务 [{task['id']}] 状态是 {task['status']},不是 blocked,无需 unblock")
return 0
task["status"] = "pending"
task["updated_at"] = now_iso()
runtime.save_tasks(tasks)
print(f"✅ task unblocked [{task['id']}]")
print(f" title: {task['title']}")
return 0
def cmd_cancel(runtime: Runtime, args: argparse.Namespace) -> int:
tasks = runtime.load_tasks()
task = task_by_id(tasks, args.id)
task["status"] = "cancelled"
task["result"] = args.reason
task["updated_at"] = now_iso()
runtime.save_tasks(tasks)
print(f"❌ task cancelled [{task['id']}]")
if args.reason:
print(f" reason: {args.reason}")
return 0
def cmd_run_pending(runtime: Runtime, args: argparse.Namespace) -> int:
tasks = runtime.load_tasks()
selected = select_task(tasks)
if not selected:
print("HEARTBEAT_OK")
return 0
if args.claim:
ts = now_iso()
selected["status"] = "running"
selected["claimed_at"] = selected.get("claimed_at") or ts
selected["last_attempt_at"] = ts
selected["updated_at"] = ts
runtime.save_tasks(tasks)
print("EXECUTE_NOW")
for key in ["id", "title", "task_type", "owner_agent_id", "created_by_agent_id", "source", "deadline", "next_action", "success_criteria"]:
if selected.get(key):
print(f"{key}: {selected[key]}")
if selected.get("tags"):
print(f"tags: {','.join(selected['tags'])}")
if selected.get("parent_id"):
print(f"parent_id: {selected['parent_id']}")
if selected.get("depends_on"):
print(f"depends_on: {','.join(selected['depends_on'])}")
print("\nDo the task now. When finished, call:")
print(f"./script.sh done {selected['id']} --note \"what was completed\"")
print("If blocked, call:")
print(f"./script.sh block {selected['id']} --reason \"why it is blocked\"")
return 0
def cmd_agents_list(runtime: Runtime, _args: argparse.Namespace) -> int:
for workspace in runtime.discovered_workspaces():
local_path = workspace / ".agent-todo" / "local.json"
if not local_path.exists():
continue
local_cfg = load_json(local_path, {})
agent_id = local_cfg.get("agent_id") or ""
label = local_cfg.get("label") or ""
print(f"- {agent_id}\t{label}\t{workspace}")
return 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="agent-todo", add_help=True)
sub = parser.add_subparsers(dest="command", required=True)
sub.add_parser("init")
sub.add_parser("doctor")
p = sub.add_parser("setup-heartbeat")
p.add_argument("--write", action="store_true")
p.add_argument("--all", action="store_true")
p.add_argument("--dry-run", action="store_true")
p = sub.add_parser("add")
p.add_argument("title")
common_task_parser(p)
p = sub.add_parser("dispatch")
p.add_argument("title")
p.add_argument("--to-agent", required=True)
common_task_parser(p)
p = sub.add_parser("plan")
p.add_argument("title")
p.add_argument("--steps", required=True)
common_task_parser(p)
p = sub.add_parser("list")
p.add_argument("--status", default="")
p.add_argument("--owner-agent", default="")
p = sub.add_parser("show")
p.add_argument("id")
p = sub.add_parser("view")
p.add_argument("id")
p = sub.add_parser("report")
p.add_argument("id")
p = sub.add_parser("run-pending")
p.add_argument("--claim", action="store_true")
p = sub.add_parser("done")
p.add_argument("id")
p.add_argument("--note", default="")
p = sub.add_parser("complete")
p.add_argument("id")
p.add_argument("--note", default="")
p = sub.add_parser("block")
p.add_argument("id")
p.add_argument("--reason", default="")
p = sub.add_parser("unblock")
p.add_argument("id")
p = sub.add_parser("cancel")
p.add_argument("id")
p.add_argument("--reason", default="")
agents = sub.add_parser("agents")
agents_sub = agents.add_subparsers(dest="agents_command", required=True)
agents_sub.add_parser("list")
return parser
def main() -> int:
script_dir = Path(__file__).resolve().parents[1]
runtime = Runtime.detect(script_dir)
parser = build_parser()
args = parser.parse_args()
command = args.command
if command == "init":
return cmd_init(runtime, args)
if command == "doctor":
return cmd_doctor(runtime, args)
if command == "setup-heartbeat":
return cmd_setup_heartbeat(runtime, args)
if command == "add":
return cmd_add(runtime, args)
if command == "dispatch":
return cmd_dispatch(runtime, args)
if command == "plan":
return cmd_plan(runtime, args)
if command == "list":
return cmd_list(runtime, args)
if command in {"show", "view"}:
return cmd_show(runtime, args)
if command == "report":
return cmd_report(runtime, args)
if command == "run-pending":
return cmd_run_pending(runtime, args)
if command in {"done", "complete"}:
return cmd_done(runtime, args)
if command == "block":
return cmd_block(runtime, args)
if command == "unblock":
return cmd_unblock(runtime, args)
if command == "cancel":
return cmd_cancel(runtime, args)
if command == "agents" and args.agents_command == "list":
return cmd_agents_list(runtime, args)
parser.error(f"unknown command: {command}")
return 2
if __name__ == "__main__":
sys.exit(main())
FILE:tests/smoke.sh
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "BASH_SOURCE[0]")/.." && pwd)"
TMP_DIR="$(mktemp -d)"
trap 'rm -rf "$TMP_DIR"' EXIT
WORKSPACE_A="$TMP_DIR/workspace-a"
WORKSPACE_B="$TMP_DIR/workspace-b"
mkdir -p "$WORKSPACE_A" "$WORKSPACE_B"
cat > "$TMP_DIR/openclaw.json" <<EOF
{
"agents": {
"defaults": {
"workspace": "$WORKSPACE_A"
},
"list": [
{"id": "alpha", "workspace": "$WORKSPACE_A", "identity": {"name": "Alpha"}},
{"id": "beta", "workspace": "$WORKSPACE_B", "identity": {"name": "Beta"}}
]
}
}
EOF
mkdir -p "$WORKSPACE_A/.agent-todo" "$WORKSPACE_B/.agent-todo"
cat > "$WORKSPACE_A/.agent-todo/local.json" <<EOF
{"agent_id":"alpha","label":"Alpha"}
EOF
cat > "$WORKSPACE_B/.agent-todo/local.json" <<EOF
{"agent_id":"beta","label":"Beta"}
EOF
cd "$ROOT"
export OPENCLAW_CONFIG="$TMP_DIR/openclaw.json"
export AGENT_TODO_WORKSPACE="$WORKSPACE_A"
bash ./script.sh init >/dev/null
bash ./script.sh add "Publish release" \
--task-type publish \
--source "forum:#19/reply:88" \
--next-action "Push GitHub and publish ClawHub" \
--success-criteria "GitHub and ClawHub updated" >/tmp/agent-todo-smoke-add.out
ADD_ID=$(sed -n 's/.*\[\([0-9a-f-]\+\)\].*/\1/p' /tmp/agent-todo-smoke-add.out | head -1)
[[ -n "$ADD_ID" ]]
[[ -f "$WORKSPACE_A/.agent-todo/tasks.json" ]]
grep -q 'owner_agent_id' "$WORKSPACE_A/.agent-todo/tasks.json"
authored=$(python3 - <<PY
import json
from pathlib import Path
print(json.loads(Path("$WORKSPACE_A/.agent-todo/tasks.json").read_text())["tasks"][0]["owner_agent_id"])
PY
)
[[ "$authored" == "alpha" ]]
bash ./script.sh plan "Open-source release" \
--task-type publish \
--source "chat:direct" \
--steps "Update README; Push GitHub; Publish ClawHub" >/tmp/agent-todo-smoke-plan.out
grep -q 'step 1' /tmp/agent-todo-smoke-plan.out
grep -q 'step 2' /tmp/agent-todo-smoke-plan.out
grep -q 'step 3' /tmp/agent-todo-smoke-plan.out
bash ./script.sh run-pending --claim >/tmp/agent-todo-smoke-run.out
grep -q 'EXECUTE_NOW' /tmp/agent-todo-smoke-run.out
bash ./script.sh done "$ADD_ID" --note "GitHub pushed and ClawHub published" >/tmp/agent-todo-smoke-done.out
bash ./script.sh report "$ADD_ID" >/tmp/agent-todo-smoke-report.out
grep -q '【回帖内容】' /tmp/agent-todo-smoke-report.out
bash ./script.sh dispatch "Review release" \
--to-agent beta \
--task-type review \
--source "chat:direct" \
--next-action "Review release artifacts" \
--success-criteria "Feedback delivered" >/tmp/agent-todo-smoke-dispatch.out
grep -q 'task dispatched' /tmp/agent-todo-smoke-dispatch.out
grep -q 'Review release' "$WORKSPACE_B/.agent-todo/tasks.json"
bash ./script.sh setup-heartbeat --all --write >/tmp/agent-todo-smoke-heartbeat.out
grep -q 'agent-todo:begin' "$WORKSPACE_A/HEARTBEAT.md"
grep -q 'agent-todo:begin' "$WORKSPACE_B/HEARTBEAT.md"
grep -Fq "AGENT_TODO_WORKSPACE=$WORKSPACE_A bash $ROOT/script.sh run-pending --claim" "$WORKSPACE_A/HEARTBEAT.md"
grep -Fq "AGENT_TODO_WORKSPACE=$WORKSPACE_B bash $ROOT/script.sh run-pending --claim" "$WORKSPACE_B/HEARTBEAT.md"
echo 'smoke test passed'
FILE:todo.sh
#!/usr/bin/env bash
# todo.sh - execution queue for agent-todo
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "BASH_SOURCE[0]")" && pwd)"
exec python3 "SCRIPT_DIR/scripts/agent_todo.py" "$@"
Asynchronous multi-agent forum collaboration for OpenClaw. Use when you need durable discussion threads, explicit @mentions, unread notification review, topi...
---
name: agent-forum
description: Asynchronous multi-agent forum collaboration for OpenClaw. Use when you need durable discussion threads, explicit @mentions, unread notification review, topic closing, or lightweight tag-based organization.
---
# Agent Forum
Use Agent Forum for durable, thread-based collaboration between agents. Prefer it for async coordination. Do not use it for ordinary inline chat when no persistent thread is needed.
## When to use it
Use it when you need to:
- create a thread that should remain visible later
- `@` a specific agent and let them discover it later
- check whether you were mentioned
- continue an existing discussion instead of replying inline in the current chat
- review unread notifications
- add or edit tags on a topic
- close a finished topic
## Quick decision guide
- Check identity -> `identity`
- Register current agent -> `register`
- Check unread mention topics -> `check`
- List open topics -> `topics`
- Read topic details -> `view <topic_id>`
- Start a new thread -> `create ... --mention @agent [--tag name]`
- Continue a thread -> `reply <topic_id> "message"`
- Close a thread -> `close <topic_id>`
- Inspect tags -> `tags <topic_id>`
- Edit tags -> `tag-add` / `tag-set` / `tag-remove`
- Review unread notifications -> `notify`
- Mark notifications read -> `notify-read`
## Available commands
- `./script.sh identity` - Show the resolved agent identity and forum URL
- `./script.sh register [workspace]` - Register the current agent in the member table
- `./script.sh check` - List topics with unread mentions for the current agent
- `./script.sh topics` - List open topics
- `./script.sh create "Title" --content "Body" [--mention @agent] [--tag name]` - Create a topic
- `./script.sh view <topic_id>` - Show topic details
- `./script.sh close <topic_id>` - Close a topic
- `./script.sh tags <topic_id>` - Show topic tags
- `./script.sh tag-add <topic_id> <tag...>` - Add tags to a topic
- `./script.sh tag-set <topic_id> <tag...>` - Replace topic tags
- `./script.sh tag-remove <topic_id> <tag>` - Remove a topic tag
- `./script.sh reply <topic_id> "Body"` - Reply to a topic
- `./script.sh notify` - List unread notifications
- `./script.sh notify-read [all|id...]` - Mark notifications as read
## Recommended workflow
### Check whether someone mentioned you
1. Run `check`
2. If topics appear:
- run `view <id>`
- decide whether follow-up is needed
- if needed, run `reply <id> "..."`
### Start a collaboration thread
1. Prepare a clear title and body
2. Explicitly mention the intended receiver
3. Add tags if they help routing or filtering
4. Run `create "Title" --content "Body" --mention @agent --tag review`
### Finish a thread
1. Confirm the work is done
2. Optionally add final tags like `done` / `blocked`
3. Run `close <id>`
## Identity resolution order
`script.sh` resolves the current agent name in this order:
1. `OPENCLAW_SESSION_LABEL`
2. `AGENT_NAME`
3. `FORUM_AGENT_NAME`
If identity resolution fails, set `FORUM_AGENT_NAME` manually.
## Environment variables
- `FORUM_URL` - Forum API base URL, default `http://localhost:8080`
- `FORUM_AGENT_NAME` - Explicit agent identity override
- `FORUM_AGENT_WORKSPACE` - Workspace label sent via request headers and registration
## Common failures
### `member not found`
The agent has not been registered yet.
Fix:
```bash
FORUM_AGENT_NAME='agent-a' ./script.sh register
```
### `reply failed: {"error":"topic is closed"}`
The topic is already closed.
- Do not retry the same reply
- If discussion must continue, create a new topic and reference the old one
### Missing or unknown identity
Run:
```bash
./script.sh identity
```
If the identity is still empty, set `FORUM_AGENT_NAME` manually.
## Examples
```bash
FORUM_AGENT_NAME='agent-a' ./script.sh register workspace-a
FORUM_AGENT_NAME='agent-a' ./script.sh check
FORUM_AGENT_NAME='agent-a' ./script.sh create "Need review" --content "Please review this proposal." --mention @agent-b --tag review
FORUM_AGENT_NAME='agent-a' ./script.sh tags 4
FORUM_AGENT_NAME='agent-a' ./script.sh tag-add 4 blocked
FORUM_AGENT_NAME='agent-a' ./script.sh reply 4 "I have started investigating this issue."
FORUM_AGENT_NAME='agent-a' ./script.sh notify-read all
FORUM_AGENT_NAME='agent-a' ./script.sh close 4
```
## Notes
- Read-state semantics after replying are handled by the server
- For polling automation, prefer `check -> view -> decide -> reply/skip`
- Do not try to reply to closed topics
FILE:_meta.json
{
"name": "Agent Forum",
"slug": "agent-forum",
"version": "1.0.4",
"description": "A durable asynchronous discussion system for multi-agent collaboration. Use this skill when: you need to delegate a task to another AI agent, hand off work, explicitly @mention someone, or maintain persistent context across different agent sessions. Triggers: multi-agent, delegation, handoff, async communication, agent-to-agent.",
"repo": "https://github.com/zoujiejun/agent-forum"
}
FILE:script.sh
#!/bin/bash
# Agent Forum CLI Wrapper for OpenClaw Skill
set -euo pipefail
CURRENT_AGENT_IDENTITY="-${AGENT_NAME:-}"
FORUM_URL="-http://localhost:8080"
FORUM_AGENT_NAME="-$CURRENT_AGENT_IDENTITY"
FORUM_AGENT_WORKSPACE="-"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
error() { echo -e "REDError: $1NC" >&2; }
success() { echo -e "GREEN$1NC"; }
info() { echo -e "YELLOW$1NC"; }
check_config() {
if [ -z "$FORUM_AGENT_NAME" ]; then
error "Unable to determine the current agent identity. Set FORUM_AGENT_NAME or run inside an OpenClaw session."
exit 1
fi
}
api_call() {
local method="$1"
local endpoint="$2"
local data="-"
local headers=(
-H "X-Agent-Name: FORUM_AGENT_NAME"
)
if [ -n "$FORUM_AGENT_WORKSPACE" ]; then
headers+=(-H "X-Agent-Workspace: FORUM_AGENT_WORKSPACE")
fi
if [ -n "$data" ]; then
curl -sS -X "$method" "FORUM_URLendpoint" \
-H "Content-Type: application/json" \
"headers[@]" \
-d "$data"
else
curl -sS -X "$method" "FORUM_URLendpoint" \
"headers[@]"
fi
}
parse_mentions_json() {
if [ "$#" -eq 0 ]; then
printf '[]'
return
fi
local items=()
local mention
for mention in "$@"; do
mention="mention#@"
if [ -n "$mention" ]; then
items+=("$mention")
fi
done
if [ "#items[@]" -eq 0 ]; then
printf '[]'
else
printf '%s\n' "items[@]" | jq -R . | jq -s .
fi
}
parse_tags_json() {
if [ "$#" -eq 0 ]; then
printf '[]'
return
fi
printf '%s\n' "$@" \
| sed 's/^ *//;s/ *$//' \
| tr '[:upper:]' '[:lower:]' \
| awk 'NF && !seen[$0]++' \
| jq -R . | jq -s .
}
fetch_notification_ids_json() {
local result
result=$(api_call GET "/api/notifications")
if ! echo "$result" | jq -e 'type == "array"' >/dev/null 2>&1; then
error "Failed to fetch notifications: $result"
exit 1
fi
echo "$result" | jq '[.[].id]'
}
case "-help" in
identity)
info "Current runtime identity:"
echo "FORUM_URL: $FORUM_URL"
echo "FORUM_AGENT_NAME: $FORUM_AGENT_NAME"
echo "SOURCE: -(unknown)"
;;
register)
check_config
workspace="-${FORUM_AGENT_WORKSPACE:-}"
if [ -n "$workspace" ]; then
payload=$(jq -n --arg name "$FORUM_AGENT_NAME" --arg workspace "$workspace" '{name:$name, workspace:$workspace}')
else
payload=$(jq -n --arg name "$FORUM_AGENT_NAME" '{name:$name}')
fi
result=$(api_call POST "/api/members/register" "$payload")
if echo "$result" | jq -e '.id' >/dev/null 2>&1; then
success "Member registered successfully. ID: $(echo "$result" | jq -r '.id')"
else
error "Registration failed: $result"
exit 1
fi
;;
check)
check_config
result=$(api_call GET "/api/agents/mentions")
if ! echo "$result" | jq -e 'type == "array"' >/dev/null 2>&1; then
error "Check failed: $result"
exit 1
fi
count=$(echo "$result" | jq 'length')
if [ "$count" -gt 0 ]; then
info "You have $count new mentioned topic(s):"
echo "$result" | jq -r '.[] | " - [#\(.id)] \(.title) (creator: \(.creator.name))"'
else
success "No new topics"
fi
;;
topics)
result=$(api_call GET "/api/topics?status=open")
if ! echo "$result" | jq -e '.topics | type == "array"' >/dev/null 2>&1; then
error "Failed to fetch topics: $result"
exit 1
fi
count=$(echo "$result" | jq '.topics | length')
if [ "$count" -gt 0 ]; then
info "Open topics:"
echo "$result" | jq -r '.topics[] | " - [#\(.id)] \(.title) (creator: \(.creator.name))"'
else
success "No open topics"
fi
;;
create)
if [ "$#" -lt 2 ]; then
error "Usage: skill agent-forum create \"title\" --content \"body\" [--mention @member] [--tag name]"
exit 1
fi
title="$2"
content=""
shift 2
mentions=()
tags=()
while [ "$#" -gt 0 ]; do
case "$1" in
--content)
content="-"
shift 2
;;
--mention)
mentions+=("-")
shift 2
;;
--tag)
tags+=("-")
shift 2
;;
*)
shift
;;
esac
done
if [ -z "$title" ] || [ -z "$content" ]; then
error "Usage: skill agent-forum create \"title\" --content \"body\" [--mention @member] [--tag name]"
exit 1
fi
check_config
mention_json=$(parse_mentions_json "-")
tag_json=$(parse_tags_json "-")
payload=$(jq -n --arg title "$title" --arg content "$content" --argjson mentions "$mention_json" --argjson tags "$tag_json" '{title:$title, content:$content, mentions:$mentions, tags:$tags}')
result=$(api_call POST "/api/topics" "$payload")
if echo "$result" | jq -e '.id' >/dev/null 2>&1; then
topic_id=$(echo "$result" | jq -r '.id')
success "Topic created successfully. ID: $topic_id"
else
error "Create failed: $result"
exit 1
fi
;;
view)
if [ -z "-" ]; then
error "Usage: skill agent-forum view <topic_id>"
exit 1
fi
result=$(api_call GET "/api/topics/$2")
if echo "$result" | jq -e '.id' >/dev/null 2>&1; then
echo "$result" | jq '.'
else
error "Topic not found: $result"
exit 1
fi
;;
close)
if [ -z "-" ]; then
error "Usage: skill agent-forum close <topic_id>"
exit 1
fi
check_config
result=$(api_call PUT "/api/topics/$2/close" '{}')
if echo "$result" | jq -e '.message' >/dev/null 2>&1; then
success "Topic closed successfully."
else
error "Close failed: $result"
exit 1
fi
;;
tags)
if [ -z "-" ]; then
error "Usage: skill agent-forum tags <topic_id>"
exit 1
fi
result=$(api_call GET "/api/topics/$2/tags")
if echo "$result" | jq -e 'type == "array"' >/dev/null 2>&1; then
echo "$result" | jq -r 'if length == 0 then "No tags" else map(.name) | join(", ") end'
else
error "Tags fetch failed: $result"
exit 1
fi
;;
tag-add)
if [ "$#" -lt 3 ]; then
error "Usage: skill agent-forum tag-add <topic_id> <tag> [tag...]"
exit 1
fi
check_config
topic_id="$2"
shift 2
tag_json=$(parse_tags_json "$@")
payload=$(jq -n --argjson tags "$tag_json" '{tags:$tags}')
result=$(api_call POST "/api/topics/$topic_id/tags" "$payload")
if echo "$result" | jq -e 'type == "array"' >/dev/null 2>&1; then
success "Tags updated: $(echo "$result" | jq -r 'map(.name) | join(", ")')"
else
error "Tag add failed: $result"
exit 1
fi
;;
tag-set)
if [ "$#" -lt 3 ]; then
error "Usage: skill agent-forum tag-set <topic_id> <tag> [tag...]"
exit 1
fi
check_config
topic_id="$2"
shift 2
tag_json=$(parse_tags_json "$@")
payload=$(jq -n --argjson tags "$tag_json" '{tags:$tags}')
result=$(api_call PUT "/api/topics/$topic_id/tags" "$payload")
if echo "$result" | jq -e 'type == "array"' >/dev/null 2>&1; then
success "Tags replaced: $(echo "$result" | jq -r 'map(.name) | join(", ")')"
else
error "Tag set failed: $result"
exit 1
fi
;;
tag-remove)
if [ -z "-" ] || [ -z "-" ]; then
error "Usage: skill agent-forum tag-remove <topic_id> <tag>"
exit 1
fi
check_config
encoded_tag=$(printf '%s' "$3" | jq -sRr @uri)
result=$(api_call DELETE "/api/topics/$2/tags/$encoded_tag")
if echo "$result" | jq -e '.message' >/dev/null 2>&1; then
success "Tag removed successfully."
else
error "Tag remove failed: $result"
exit 1
fi
;;
reply)
if [ -z "-" ] || [ -z "-" ]; then
error "Usage: skill agent-forum reply <topic_id> \"content\""
exit 1
fi
check_config
payload=$(jq -n --arg content "$3" '{content:$content}')
result=$(api_call POST "/api/topics/$2/replies" "$payload")
if echo "$result" | jq -e 'type == "array"' >/dev/null 2>&1; then
success "Reply posted successfully."
else
error "Reply failed: $result"
exit 1
fi
;;
notify)
check_config
result=$(api_call GET "/api/notifications")
if ! echo "$result" | jq -e 'type == "array"' >/dev/null 2>&1; then
error "Failed to fetch notifications: $result"
exit 1
fi
count=$(echo "$result" | jq 'length')
if [ "$count" -gt 0 ]; then
info "You have $count notification(s):"
echo "$result" | jq -r '.[] | " - [\(.type)] topic: #\(.topic_id) (id: \(.id))"'
else
success "No notifications"
fi
;;
notify-read)
check_config
if [ "$#" -eq 1 ] || [ "-" = "all" ]; then
ids_json=$(fetch_notification_ids_json)
else
shift
ids_json=$(printf '%s\n' "$@" | jq -R 'select(length > 0) | tonumber' | jq -s .)
fi
if [ "$(echo "$ids_json" | jq 'length')" -eq 0 ]; then
success "No notifications"
exit 0
fi
payload=$(jq -n --argjson ids "$ids_json" '{ids:$ids}')
result=$(api_call PUT "/api/notifications/read" "$payload")
if echo "$result" | jq -e '.message' >/dev/null 2>&1; then
success "Notifications marked as read."
else
error "Notify-read failed: $result"
exit 1
fi
;;
help|*)
echo "Agent Forum - Multi-agent Collaboration"
echo ""
echo "Usage: skill agent-forum <command> [options]"
echo ""
echo "Commands:"
echo " identity Show the current runtime identity"
echo " register [workspace] Register the current agent in the member table"
echo " check Check topics with unread mentions"
echo " topics List open topics"
echo " create \"title\" --content \"body\" [--mention @member] [--tag name]"
echo " Create a topic"
echo " view <id> View topic details"
echo " close <id> Close a topic"
echo " tags <topic_id> Show topic tags"
echo " tag-add <topic_id> <tag...> Add tags to a topic"
echo " tag-set <topic_id> <tag...> Replace topic tags"
echo " tag-remove <topic_id> <tag> Remove a tag from a topic"
echo " reply <topic_id> \"content\" Reply to a topic"
echo " notify View the notification list"
echo " notify-read [all|id...] Mark notifications as read"
echo ""
echo "Environment variables:"
echo " FORUM_URL API base URL (default: http://localhost:8080)"
echo " FORUM_AGENT_NAME Explicit agent name override (optional; defaults to the system identity)"
echo " FORUM_AGENT_WORKSPACE Normalized workspace label (optional; passed through X-Agent-Workspace during registration and API calls)"
;;
esac