diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 64e28e9..28934de 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -15,26 +15,24 @@ jobs:
backend-quality:
name: Backend Quality
runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: backend
steps:
- name: Check out repository
uses: actions/checkout@v5
- - name: Set up Python
- uses: actions/setup-python@v5
+ - name: Set up Bun
+ uses: oven-sh/setup-bun@v2
with:
- python-version: "3.12"
-
- - name: Set up uv
- uses: astral-sh/setup-uv@v6
- with:
- enable-cache: true
+ bun-version: latest
- name: Install dependencies
- run: uv sync --dev
+ run: bun install --frozen-lockfile
- name: Run backend quality gate
- run: make check
+ run: bun run check
frontend-quality:
name: Frontend Quality
@@ -47,24 +45,25 @@ jobs:
- name: Check out repository
uses: actions/checkout@v5
- - name: Set up Node.js
- uses: actions/setup-node@v5
+ - name: Set up Bun
+ uses: oven-sh/setup-bun@v2
with:
- node-version: "22"
- cache: npm
- cache-dependency-path: taskboard-electron/package-lock.json
+ bun-version: latest
- name: Install dependencies
- run: npm ci
+ run: bun install --frozen-lockfile
+
+ - name: Typecheck (tsc)
+ run: bun run typecheck
- name: Lint (ESLint)
- run: npm run lint
+ run: bun run lint
- name: Format check (Prettier)
- run: npm run format:check
+ run: bun run format:check
- name: Unit tests
- run: npm test
+ run: bun run test
- - name: Renderer build check
- run: npm run build:check
+ - name: Renderer/main build check (Bun.build)
+ run: bun run build:check
diff --git a/.gitignore b/.gitignore
index 5edfa31..5ca70c2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,11 +1,17 @@
# Python-generated files
__pycache__/
*.py[oc]
+.pytest_cache/
+.ruff_cache/
+.python-version
build/
dist/
wheels/
*.egg-info
+# JS/Bun dependencies
+node_modules/
+
# Virtual environments
.venv
.coverage
diff --git a/.python-version b/.python-version
deleted file mode 100644
index e4fba21..0000000
--- a/.python-version
+++ /dev/null
@@ -1 +0,0 @@
-3.12
diff --git a/AGENTS.md b/AGENTS.md
index 814e648..ccaea42 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,89 +1,95 @@
## Project Overview
-AgentForge is a macOS desktop app (Electron + Python) that provides a kanban-style task board for orchestrating AI coding agents (**Claude Code or OpenAI Codex CLI**). The Python backend manages task scheduling, execution, and persistence; the React frontend renders the board and streams live output. Tasks can also be created/monitored from chat channels (Telegram / Slack / Feishu / WeChat), and a **Skill Library** distills recurring task patterns into reusable Claude Code skills.
+AgentForge is a macOS desktop app (Electron + Bun/TypeScript) that provides a kanban-style task board for orchestrating AI coding agents (**Claude Code or OpenAI Codex CLI**). The TypeScript backend (running on Bun) manages task scheduling, execution, and persistence; the React frontend renders the board and streams live output. Tasks can also be created/monitored from chat channels (Telegram / Slack / Feishu / WeChat), and a **Skill Library** distills recurring task patterns into reusable Claude Code skills.
+
+The entire project is TypeScript-only and uses **Bun** to build, run, test, and compile (no Python, no Node toolchain — Electron's bundled Node runs the packaged app, but all tooling is Bun).
## Commands
-### Python Backend
+### Backend (`backend/`)
```bash
-# Run backend directly (dev)
-uv run taskboard.py
-
-# Run with explicit port (default 9712)
-uv run taskboard.py # listens on 127.0.0.1:9712 (loopback only — not network-exposed)
+# Run backend directly (dev) — listens on 127.0.0.1:9712 (loopback only)
+cd backend && bun taskboard.ts
# Verify health
curl http://127.0.0.1:9712/api/health
-# Build PyInstaller binary (must run from project root)
-uv run pyinstaller --onefile --name taskboard \
- --distpath taskboard-electron/resources \
- --hidden-import croniter --hidden-import dateutil --hidden-import pytz \
- taskboard.py
+# Compile single-file binary for packaging (replaces PyInstaller)
+cd taskboard-electron && bun scripts/build-backend.ts
+# (equivalent: cd backend && bun run compile)
```
### Electron App
```bash
-# Dev: starts Electron + Vite dev server (spawns uv run taskboard.py automatically)
-cd taskboard-electron && npm start
+# Dev: Bun-builds main/preload/renderer, launches Electron with watch + reload
+# (spawns `bun backend/taskboard.ts` automatically)
+cd taskboard-electron && bun run start
# Build distributable DMG (arm64)
-cd taskboard-electron && npm run make
+cd taskboard-electron && bun run make
# Output: taskboard-electron/out/make/AgentForge-1.0.0-arm64.dmg
```
### Tests & Quality
```bash
# Backend (⭐ backend-quality CI job runs `make check`)
-make check # ruff lint + ruff format --check + pytest (coverage, fail_under=90)
-make test # pytest only
-make lint # ruff check ONLY (no format-check, no tests — NOT the CI gate)
-make format # apply ruff formatting
-uv run pytest -q # run the Python suite directly
+make check # = cd backend && bun run check (tsc --noEmit + prettier --check + bun test --coverage)
+make test # bun test only
+make lint # tsc typecheck ONLY (no format-check, no tests — NOT the CI gate)
+make format # apply prettier formatting
+cd backend && bun test # run the suite directly
-# Frontend (⭐ frontend-quality CI job runs all four, from taskboard-electron/)
+# Frontend (⭐ frontend-quality CI job runs all five, from taskboard-electron/)
cd taskboard-electron
-npm run lint # ESLint (flat config, eslint.config.mjs)
-npm run format:check # Prettier --check (npm run format to apply)
-npm test # node --test (pins TZ=Asia/Shanghai — date tests assert local wall time)
-npm run build:check # vite renderer build — catches compile/import errors
+bun run typecheck # tsc over renderer + main/preload tsconfigs
+bun run lint # ESLint (flat config, typescript-eslint, eslint.config.mjs)
+bun run format:check # Prettier --check (bun run format to apply)
+bun run test # bun test (pins TZ=Asia/Shanghai — date tests assert local wall time)
+bun run build:check # Bun.build of main/preload/renderer — catches compile/import errors
```
-CI (`.github/workflows/ci.yml`) runs two jobs: **backend-quality** (`make check`)
-and **frontend-quality** (lint + format check + tests + build). The workflow uses
-`concurrency` to cancel superseded runs on the same ref. There are 34 pytest files
-under `tests/` (backend coverage gate is 90%) plus `.test.mjs` files beside the renderer.
+CI (`.github/workflows/ci.yml`) runs two jobs: **backend-quality** (`bun run check` in `backend/`)
+and **frontend-quality** (typecheck + lint + format check + tests + build). The workflow uses
+`concurrency` to cancel superseded runs on the same ref. Backend tests live in `backend/tests/*.test.ts`
+(bun:test), frontend tests beside the renderer sources as `*.test.ts`.
## Architecture
### Two-process model
-The Electron main process (`taskboard-electron/src/main.js`) spawns the Python backend on startup and kills it on quit. The React renderer communicates with the backend exclusively via HTTP on `127.0.0.1:9712` (loopback only). There is no WebSocket or IPC for data — the renderer polls the REST API with `fetch()`.
+The Electron main process (`taskboard-electron/src/main.ts`) spawns the Bun backend on startup and kills it on quit. The React renderer communicates with the backend exclusively via HTTP on `127.0.0.1:9712` (loopback only). There is no WebSocket or IPC for data — the renderer polls the REST API with `fetch()`.
-### Python backend (`taskboard.py`)
-Single-file HTTP server (`BaseHTTPRequestHandler`) with:
-- **`TaskDB`** — SQLite layer at `~/.agentforge/tasks.db`. Thread-safe with a lock. Stores tasks, run history, and streaming output events.
-- **`AgentExecutor`** — Runs the agent CLI: `claude -p … --output-format stream-json --verbose --permission-mode bypassPermissions`, or `codex exec --json …`. Parses the NDJSON stream and persists each event to `task_output_events`.
-- **`TaskScheduler`** — Background thread that polls every 2 seconds for due tasks. Supports four schedule types:
+### Bun backend (`backend/`)
+TypeScript modules served by `Bun.serve` (entry `backend/taskboard.ts`):
+- **`src/db.ts` — `TaskDB`** — SQLite layer (bun:sqlite) at `~/.agentforge/tasks.db`. Stores tasks, run history, and streaming output events. Method names keep the original Python snake_case spelling (they double as API JSON keys).
+- **`src/executor.ts` — `AgentExecutor`** — Runs the agent CLI: `claude -p … --output-format stream-json --verbose --permission-mode bypassPermissions`, or `codex exec --json …`. Parses the NDJSON stream and persists each event to `task_output_events`.
+- **`src/scheduler.ts` — `TaskScheduler`** — Polls every 2 seconds for due tasks. Supports four schedule types:
- `immediate`: runs as soon as scheduled
- `delayed`: runs after N seconds (relative time)
- `scheduled_at`: runs once at a specific datetime (absolute time)
- - `cron`: recurring schedule using croniter for cron expression evaluation
-- **`Heartbeat`** — Background watcher: on a cron/interval it runs a `check_prompt` via an agent that returns a JSON decision (idle/trigger/resume/notify) and may auto-create tasks.
-- **Skill Library** — `TaskScheduler.run_skill_sweep` periodically (or via the manual "Scan" button) asks an agent to detect recurring patterns across completed runs (`skill_patterns` table), distills candidates into standard Claude Code `SKILL.md` files using the vendored `vendor/skill-creator`, and on approval writes them to `~/.agentforge/skills` symlinked into both `~/.claude/skills` and `~/.agents/skills`. Off by default (`skill_library_enabled` setting).
-- **Channels** (`channels/`) — Optional Telegram / Slack / Feishu / WeChat bridges (a `MessageBus` in `taskboard_bus.py` decouples them from the scheduler). Feishu uses a lark WebSocket long-connection.
-- **REST API** — Endpoints under `/api/tasks*`, `/api/heartbeats*`, `/api/skill-patterns`, `/api/skills*`, `/api/settings`, `/api/channels/*`, `/api/health`.
-
-### Electron main process (`taskboard-electron/src/main.js`)
-- In **dev mode**: `app.getAppPath()` returns `taskboard-electron/`, so `path.join(app.getAppPath(), '..')` resolves to project root for `uv run taskboard.py`. The `cwd` option must point to project root when spawning.
-- In **packaged mode**: uses the binary at `resources/taskboard` bundled inside the `.app`.
+ - `cron`: recurring schedule using cron-parser for cron expression evaluation
+- **Heartbeats** — Background watcher: on a cron/interval it runs a `check_prompt` via an agent that returns a JSON decision (idle/trigger/resume/notify) and may auto-create tasks.
+- **Skill Library** (`src/skills.ts` + scheduler) — `TaskScheduler.run_skill_sweep` periodically (or via the manual "Scan" button) asks an agent to detect recurring patterns across completed runs (`skill_patterns` table), distills candidates into standard Claude Code `SKILL.md` files using the vendored `vendor/skill-creator`, and on approval writes them to `~/.agentforge/skills` symlinked into both `~/.claude/skills` and `~/.agents/skills`. Off by default (`skill_library_enabled` setting).
+- **Channels** (`src/channels/`) — Optional Telegram / Slack / Feishu / WeChat bridges (a `MessageBus` in `src/bus.ts` decouples them from the scheduler). Feishu uses a lark WebSocket long-connection (`@larksuiteoapi/node-sdk`).
+- **REST API** (`src/api.ts`, `src/server.ts`) — Endpoints under `/api/tasks*`, `/api/heartbeats*`, `/api/skill-patterns`, `/api/skills*`, `/api/settings`, `/api/channels/*`, `/api/health`.
+
+### Electron main process (`taskboard-electron/src/main.ts`)
+- In **dev mode**: `app.getAppPath()` returns `taskboard-electron/`, so `path.join(app.getAppPath(), '..')` resolves to project root for `bun backend/taskboard.ts`. The `cwd` option must point to project root when spawning.
+- In **packaged mode**: uses the `bun build --compile` binary at `resources/taskboard` bundled inside the `.app`.
- Polls `/api/health` (15s timeout) before loading the UI.
- Exposes `window.electronAPI.selectDirectory()` to renderer via context bridge for native directory picker.
-### React frontend (`taskboard-electron/src/renderer/App.jsx`)
-Single large component (~4200 lines). Key design points:
+### Build pipeline (`taskboard-electron/scripts/`)
+- `build.ts` — `Bun.build` bundles main (CJS, electron external), preload (CJS), and renderer (HTML entrypoint → `.bun/renderer/`). Replaces the old Vite plugin.
+- `dev.ts` — watch-rebuild + Electron launcher; renderer rebuilds trigger window reload (main.ts watches `.bun/renderer`), backend `.ts` changes restart the backend.
+- `build-backend.ts` — `bun build --compile` of `backend/taskboard.ts` into `resources/taskboard`.
+- electron-forge is retained only for packaging/DMG (`bunx electron-forge package|make`); `forge.config.js` ships `.bun/` output via packager ignore rules.
+
+### React frontend (`taskboard-electron/src/renderer/App.tsx`)
+Single large component (~6200 lines). Key design points:
- `API` constant hardcoded to `http://127.0.0.1:9712/api`.
- Three top-level views (tab switch): **Tasks** (kanban), **Heartbeats**, **Skills**.
- Kanban columns: **Queue** (pending/scheduled/blocked) → **Running** → **Done** (completed/failed/cancelled).
-- `FormattedOutput` component parses stream-json events (type: `user`/`assistant`/`result`/`error`) and renders colorized output; trace/event aggregation helpers live in `traceSteps.mjs` (tested by `traceSteps.test.mjs`).
+- `FormattedOutput` component parses stream-json events (type: `user`/`assistant`/`result`/`error`) and renders colorized output; trace/event aggregation helpers live in `traceSteps.ts` (tested by `traceSteps.test.ts`).
+- Backend payload interfaces (snake_case) live in `src/renderer/types.ts`.
- Task creation supports four schedule types:
- `immediate`: run immediately
- `delayed`: run after N seconds
@@ -100,6 +106,6 @@ Single large component (~4200 lines). Key design points:
## Workflow Rules
### Always run `make check` after changing code
-- After any change to Python code (or before pushing / reporting done), run **`make check`** — not `make lint`. `make lint` only runs `ruff check`; it skips `ruff format --check` and the tests, so it will pass while CI still fails on formatting or a broken test.
+- After any change to backend code (or before pushing / reporting done), run **`make check`** — not `make lint`. `make lint` only runs the tsc typecheck; it skips `prettier --check` and the tests, so it will pass while CI still fails on formatting or a broken test.
- If `make check` reports formatting diffs, run `make format` to fix them, then re-run `make check`.
-- For frontend-only changes, run the frontend gate from `taskboard-electron/`: `npm run lint && npm run format:check && npm test && npm run build:check` (this is exactly what the frontend-quality CI job runs). If `format:check` fails, run `npm run format` to fix.
+- For frontend-only changes, run the frontend gate from `taskboard-electron/`: `bun run typecheck && bun run lint && bun run format:check && bun run test && bun run build:check` (this is exactly what the frontend-quality CI job runs). If `format:check` fails, run `bun run format` to fix.
diff --git a/Makefile b/Makefile
index de82100..07734f8 100644
--- a/Makefile
+++ b/Makefile
@@ -1,19 +1,19 @@
-.PHONY: help build-backend build-electron package-dmg clean install-deps lint format format-check test test-cov check
+.PHONY: help build-backend build-electron package-dmg clean install-deps lint format format-check test test-cov check dev-backend dev-electron check-backend check-dmg
# 项目配置
PROJECT_NAME = AgentForge
-BACKEND_SRC = taskboard.py
+BACKEND_DIR = backend
BACKEND_BINARY = taskboard-electron/resources/taskboard
ELECTRON_DIR = taskboard-electron
DMG_OUTPUT = $(ELECTRON_DIR)/out/make/$(PROJECT_NAME)-1.0.0-arm64.dmg
help:
- @echo "AgentForge 打包工具"
+ @echo "AgentForge 打包工具 (Bun + TypeScript)"
@echo ""
@echo "可用命令:"
@echo " make help - 显示此帮助信息"
- @echo " make install-deps - 安装项目依赖"
- @echo " make build-backend - 构建Python后端二进制文件"
+ @echo " make install-deps - 安装项目依赖 (bun install)"
+ @echo " make build-backend - 编译后端单文件二进制 (bun build --compile)"
@echo " make build-electron - 构建Electron应用"
@echo " make package-dmg - 打包为DMG文件(包含所有步骤)"
@echo " make clean - 清理构建文件"
@@ -21,30 +21,25 @@ help:
@echo "快速打包: make package-dmg"
install-deps:
- @echo "安装Python依赖..."
- uv add pyinstaller croniter python-dateutil pytz
+ @echo "安装后端依赖..."
+ cd $(BACKEND_DIR) && bun install
@echo "安装Electron依赖..."
- cd $(ELECTRON_DIR) && npm install
+ cd $(ELECTRON_DIR) && bun install
build-backend:
- @echo "构建Python后端二进制文件..."
- uv run pyinstaller --onefile --name taskboard \
- --distpath $(ELECTRON_DIR)/resources \
- --hidden-import croniter --hidden-import dateutil --hidden-import pytz \
- --add-data vendor/skill-creator:vendor/skill-creator \
- --add-data channels/weixin_bridge:channels/weixin_bridge \
- $(BACKEND_SRC)
+ @echo "编译后端单文件二进制 (bun build --compile)..."
+ cd $(ELECTRON_DIR) && bun scripts/build-backend.ts
@echo "后端二进制文件位置: $(BACKEND_BINARY)"
@ls -lh $(BACKEND_BINARY)
build-electron:
@echo "构建Electron应用..."
- cd $(ELECTRON_DIR) && SKIP_BACKEND_BUILD=1 npm run package
+ cd $(ELECTRON_DIR) && bun scripts/build.ts && SKIP_BACKEND_BUILD=1 bunx electron-forge package
@echo "Electron应用构建完成"
-package-dmg: build-backend build-electron
+package-dmg: build-backend
@echo "打包DMG文件..."
- cd $(ELECTRON_DIR) && SKIP_BACKEND_BUILD=1 npm run make
+ cd $(ELECTRON_DIR) && bun scripts/build.ts && SKIP_BACKEND_BUILD=1 bunx electron-forge make
@if [ -f "$(DMG_OUTPUT)" ]; then \
echo "DMG文件生成成功: $(DMG_OUTPUT)"; \
ls -lh "$(DMG_OUTPUT)"; \
@@ -55,20 +50,19 @@ package-dmg: build-backend build-electron
clean:
@echo "清理构建文件..."
- rm -rf build/
rm -rf $(ELECTRON_DIR)/out/
- rm -rf $(ELECTRON_DIR)/.vite/
+ rm -rf $(ELECTRON_DIR)/.bun/
rm -f $(BACKEND_BINARY)
@echo "清理完成"
# 开发相关命令
dev-backend:
@echo "启动后端开发服务器..."
- uv run python $(BACKEND_SRC)
+ cd $(BACKEND_DIR) && bun taskboard.ts
dev-electron:
@echo "启动Electron开发模式..."
- cd $(ELECTRON_DIR) && npm start
+ cd $(ELECTRON_DIR) && bun run start
# 检查命令
check-backend:
@@ -84,23 +78,24 @@ check-dmg:
fi
lint:
- @echo "运行 Ruff lint..."
- uv run ruff check .
+ @echo "运行 TypeScript 类型检查..."
+ cd $(BACKEND_DIR) && bun run typecheck
format:
- @echo "运行 Ruff format..."
- uv run ruff format .
+ @echo "运行 Prettier format..."
+ cd $(BACKEND_DIR) && bun run format
format-check:
@echo "检查代码格式..."
- uv run ruff format --check .
+ cd $(BACKEND_DIR) && bun run format:check
test:
- @echo "运行 Python 测试..."
- uv run pytest
+ @echo "运行后端测试..."
+ cd $(BACKEND_DIR) && bun test
test-cov:
- @echo "运行 Python 测试并检查覆盖率..."
- uv run pytest --cov --cov-report=term-missing
+ @echo "运行后端测试并检查覆盖率..."
+ cd $(BACKEND_DIR) && bun test --coverage
-check: lint format-check test-cov
+check:
+ cd $(BACKEND_DIR) && bun run check
diff --git a/README.md b/README.md
index a0eb28a..34dbe48 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@
[](LICENSE)
[](https://github.com/releases)
-[](https://python.org)
+[](https://bun.sh) [](https://www.typescriptlang.org)
[](https://docs.anthropic.com/en/docs/claude-code)
**Website**: https://agentforge-landing-weld.vercel.app/
@@ -64,8 +64,7 @@ Distilled skills are standard Claude Code `SKILL.md` files (canonical copy in `~
## Requirements
- macOS 12.0+ (Apple Silicon or Intel)
-- Python 3.12+
-- Node.js 18+
+- [Bun](https://bun.sh) 1.3+
- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) installed and on `PATH` (default agent)
- [OpenAI Codex CLI](https://github.com/openai/codex) on `PATH` — optional, required only if using Codex as agent backend (`npm install -g @openai/codex`)
@@ -99,30 +98,25 @@ make package-dmg
git clone https://github.com/your-org/agentforge.git
cd agentforge
-uv sync
-cd taskboard-electron && npm install && cd ..
+make install-deps # bun install in backend/ and taskboard-electron/
-# Terminal 1: start Python backend
-uv run taskboard.py
-
-# Terminal 2: start Electron + Vite dev server
-cd taskboard-electron && npm start
+# Start Electron + backend (Bun builds, watches, and spawns the backend)
+cd taskboard-electron && bun run start
```
---
## Troubleshooting
-### `npm install` hangs or freezes
+### `bun install` hangs or freezes
This is the most common setup issue. The Electron binary (~100 MB) is downloaded from GitHub and may stall on slow connections or in China.
**Quick fix — use mirrors:**
```bash
-npm config set registry https://registry.npmmirror.com
export ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/
-cd taskboard-electron && npm install
+cd taskboard-electron && bun install --registry https://registry.npmmirror.com
```
**Full guide:** [docs/installation-troubleshooting.md](docs/installation-troubleshooting.md) covers:
@@ -310,7 +304,7 @@ Or configure from the desktop app's settings page.
WeChat setup (experimental)
-WeChat uses a Node.js sidecar bridge — no environment variables needed. Configure and enable it via the API or the desktop app's settings page.
+WeChat uses a Bun/TypeScript sidecar bridge — no environment variables needed. Configure and enable it via the API or the desktop app's settings page.
### 1. Enable via API
@@ -337,7 +331,7 @@ On first launch the bridge will request a QR code login. Scan it with your WeCha
-> See [`channels/README.md`](channels/README.md) for detailed setup, notification behavior, and adding custom channels.
+Channel adapters live in [`backend/src/channels/`](backend/src/channels/). Configure them from the desktop app settings page or the REST endpoints shown above.
---
@@ -419,9 +413,8 @@ To keep the backend running persistently without the desktop app:
com.agentforge.taskboard
ProgramArguments
- /usr/local/bin/uv
- run
- /path/to/agentforge/taskboard.py
+ /usr/local/bin/bun
+ /path/to/agentforge/backend/taskboard.ts
WorkingDirectory
/path/to/agentforge
@@ -447,7 +440,7 @@ launchctl load ~/Library/LaunchAgents/com.agentforge.taskboard.plist
```
┌──────────────────┐ HTTP/JSON ┌──────────────────┐
-│ React Frontend │ <────────────────> │ Python Backend │
+│ React Frontend │ <────────────────> │ Bun/TS Backend │
│ (Kanban UI) │ localhost:9712 │ (Scheduler+API) │
└──────────────────┘ └───────┬──────────┘
|
@@ -456,9 +449,9 @@ launchctl load ~/Library/LaunchAgents/com.agentforge.taskboard.plist
[ SQLite DB ] [ Scheduler ] [ Claude CLI ]
```
-- **Python backend** (`taskboard.py`) — single-file `BaseHTTPRequestHandler` server. Manages tasks in SQLite (`~/.agentforge/tasks.db`), runs `claude` or `codex` CLI via `AgentExecutor`, and schedules work with `TaskScheduler` (polls every 2 s, supports cron via `croniter`).
-- **Electron shell** (`taskboard-electron/`) — spawns the Python backend on start, kills it on quit. Loads React renderer from Vite dev server (dev) or bundled assets (prod).
-- **React frontend** (`App.jsx`) — single-component kanban board that polls the REST API and renders colorized streaming output.
+- **Bun/TypeScript backend** (`backend/`) — `Bun.serve` HTTP server. Manages tasks in SQLite via `bun:sqlite` (`~/.agentforge/tasks.db`), runs `claude` or `codex` CLI via `AgentExecutor`, and schedules work with `TaskScheduler` (polls every 2 s, supports cron via `cron-parser`).
+- **Electron shell** (`taskboard-electron/`) — spawns the backend on start, kills it on quit. Renderer/main/preload are bundled with `Bun.build` (`scripts/build.ts`); in dev `bun run start` watches and reloads.
+- **React frontend** (`App.tsx`) — single-component kanban board that polls the REST API and renders colorized streaming output.
---
@@ -468,14 +461,14 @@ Contributions are welcome! Here's how to get started:
1. Fork the repository and create a feature branch.
2. Start the app in development mode (see [Option 3](#option-3-development-mode) above).
-3. Make your changes and verify them manually — there are no automated tests.
+3. Make your changes and run the relevant Bun quality gate (`make check` for backend changes; the frontend gate from `taskboard-electron/` for renderer/Electron changes).
4. Open a pull request with a clear description of the change.
**Key files:**
-- `taskboard.py` — entire Python backend (DB, scheduler, executor, HTTP handlers)
-- `taskboard-electron/src/main.js` — Electron main process
-- `taskboard-electron/src/renderer/App.jsx` — React frontend (~1500 lines)
-- `channels/` — pluggable chat channel adapters (Telegram, Slack, Feishu, WeChat)
+- `backend/` — entire TypeScript backend (DB, scheduler, executor, HTTP API, channels)
+- `taskboard-electron/src/main.ts` — Electron main process
+- `taskboard-electron/src/renderer/App.tsx` — React frontend
+- `backend/src/channels/` — pluggable chat channel adapters (Telegram, Slack, Feishu, WeChat)
- `skills/agentforge/` — Claude Code skill for agent-to-agent delegation
---
diff --git a/README.zh.md b/README.zh.md
index 195e17d..f9e1b06 100644
--- a/README.zh.md
+++ b/README.zh.md
@@ -4,7 +4,7 @@
[](LICENSE)
[](https://github.com/releases)
-[](https://python.org)
+[](https://bun.sh) [](https://www.typescriptlang.org)
[](https://docs.anthropic.com/en/docs/claude-code)
**官网**: https://agentforge-landing-weld.vercel.app/
@@ -63,8 +63,7 @@
## 环境要求
- macOS 12.0+(Apple Silicon 或 Intel)
-- Python 3.12+
-- Node.js 18+
+- [Bun](https://bun.sh) 1.3+
- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code)(已安装并在 `PATH` 中)
---
@@ -97,30 +96,25 @@ make package-dmg
git clone https://github.com/your-org/agentforge.git
cd agentforge
-uv sync
-cd taskboard-electron && npm install && cd ..
+make install-deps # bun install in backend/ and taskboard-electron/
-# 终端 1:启动 Python 后端
-uv run taskboard.py
-
-# 终端 2:启动 Electron + Vite 开发服务
-cd taskboard-electron && npm start
+# 启动 Electron + 后端(Bun 负责构建、监听并自动拉起后端)
+cd taskboard-electron && bun run start
```
---
## 常见问题
-### `npm install` 卡住不动
+### `bun install` 卡住不动
这是最常见的安装问题。Electron 二进制包约 100MB,从 GitHub 下载,在国内网络环境下极易卡住。
**快速解决——使用国内镜像:**
```bash
-npm config set registry https://registry.npmmirror.com
export ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/
-cd taskboard-electron && npm install
+cd taskboard-electron && bun install --registry https://registry.npmmirror.com
```
**完整排查指南:** [docs/installation-troubleshooting.md](docs/installation-troubleshooting.md),涵盖:
@@ -285,7 +279,7 @@ curl -X POST http://127.0.0.1:9712/api/feishu/settings \
-> 详细配置、通知行为及自定义频道开发,请参阅 [`channels/README.md`](channels/README.md)。
+频道适配器位于 [`backend/src/channels/`](backend/src/channels/),可在桌面应用设置页或上面的 REST 接口中配置。
---
@@ -367,9 +361,8 @@ AgentForge 自动处理调度、依赖追踪和结果传递。
com.agentforge.taskboard
ProgramArguments
- /usr/local/bin/uv
- run
- /path/to/agentforge/taskboard.py
+ /usr/local/bin/bun
+ /path/to/agentforge/backend/taskboard.ts
WorkingDirectory
/path/to/agentforge
@@ -395,7 +388,7 @@ launchctl load ~/Library/LaunchAgents/com.agentforge.taskboard.plist
```
┌──────────────────┐ HTTP/JSON ┌──────────────────┐
-│ React 前端 │ <────────────────> │ Python 后端 │
+│ React 前端 │ <────────────────> │ Bun/TS 后端 │
│ (看板 UI) │ localhost:9712 │ (调度器+API) │
└──────────────────┘ └───────┬──────────┘
|
@@ -404,9 +397,9 @@ launchctl load ~/Library/LaunchAgents/com.agentforge.taskboard.plist
[ SQLite DB ] [ 调度器 ] [ Claude CLI ]
```
-- **Python 后端**(`taskboard.py`)—— 单文件 `BaseHTTPRequestHandler` 服务。在 SQLite(`~/.agentforge/tasks.db`)中管理任务,通过 `AgentExecutor` 运行 `claude` CLI,通过 `TaskScheduler` 调度任务(每 2 秒轮询,支持通过 `croniter` 解析 cron 表达式)。
-- **Electron 外壳**(`taskboard-electron/`)—— 启动时创建 Python 后端进程,退出时终止它。开发模式下从 Vite 开发服务器加载 React 渲染器,生产模式下使用打包后的静态资源。
-- **React 前端**(`App.jsx`)—— 单组件看板,轮询 REST API 并渲染带颜色的流式输出。
+- **Bun/TypeScript 后端**(`backend/`)—— `Bun.serve` HTTP 服务。通过 `bun:sqlite` 在 SQLite(`~/.agentforge/tasks.db`)中管理任务,通过 `AgentExecutor` 运行 `claude` / `codex` CLI,通过 `TaskScheduler` 调度任务(每 2 秒轮询,支持通过 `cron-parser` 解析 cron 表达式)。
+- **Electron 外壳**(`taskboard-electron/`)—— 启动时创建后端进程,退出时终止它。主进程/preload/渲染器均由 `Bun.build` 打包(`scripts/build.ts`);开发模式 `bun run start` 自动监听并热重载。
+- **React 前端**(`App.tsx`)—— 单组件看板,轮询 REST API 并渲染带颜色的流式输出。
---
@@ -416,14 +409,14 @@ launchctl load ~/Library/LaunchAgents/com.agentforge.taskboard.plist
1. Fork 本仓库并创建功能分支。
2. 以开发模式启动应用(参见[方式三](#方式三开发模式))。
-3. 修改代码并手动验证——项目目前没有自动化测试。
+3. 修改代码并运行对应的 Bun 质量门禁(后端改动运行 `make check`;前端/Electron 改动在 `taskboard-electron/` 下运行前端门禁)。
4. 提交 PR,并清晰描述改动内容。
**关键文件:**
-- `taskboard.py` —— 整个 Python 后端(数据库、调度器、执行器、HTTP 处理器)
-- `taskboard-electron/src/main.js` —— Electron 主进程
-- `taskboard-electron/src/renderer/App.jsx` —— React 前端(约 1500 行)
-- `channels/` —— 可插拔消息频道适配器
+- `backend/` —— 整个 TypeScript 后端(数据库、调度器、执行器、HTTP API、频道)
+- `taskboard-electron/src/main.ts` —— Electron 主进程
+- `taskboard-electron/src/renderer/App.tsx` —— React 前端
+- `backend/src/channels/` —— 可插拔消息频道适配器
- `skills/agentforge/` —— 用于智能体间委托的 Claude Code Skill
---
diff --git a/backend/bun.lock b/backend/bun.lock
new file mode 100644
index 0000000..e8a9a07
--- /dev/null
+++ b/backend/bun.lock
@@ -0,0 +1,171 @@
+{
+ "lockfileVersion": 1,
+ "configVersion": 1,
+ "workspaces": {
+ "": {
+ "name": "agentforge-backend",
+ "dependencies": {
+ "@larksuiteoapi/node-sdk": "^1.55.0",
+ "@slack/socket-mode": "^2.0.4",
+ "@slack/web-api": "^7.13.0",
+ "cron-parser": "^5.4.0",
+ },
+ "devDependencies": {
+ "@types/bun": "^1.3.5",
+ "prettier": "^3.8.4",
+ "typescript": "^5.9.3",
+ },
+ },
+ },
+ "packages": {
+ "@larksuiteoapi/node-sdk": ["@larksuiteoapi/node-sdk@1.66.1", "", { "dependencies": { "axios": "~1.13.3", "lodash.identity": "^3.0.0", "lodash.merge": "^4.6.2", "lodash.pickby": "^4.6.0", "protobufjs": "^7.2.6", "qs": "^6.14.2", "ws": "^8.19.0" } }, "sha512-W1rIAs/8Oc/rEYuWc0sxGvR8iLwd8p5D2RS4ODMqs8htIxK8yIa8sb22EDv/OEBqUpKXZaNLydTz7Oq8HOQROg=="],
+
+ "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
+
+ "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="],
+
+ "@protobufjs/codegen": ["@protobufjs/codegen@2.0.5", "", {}, "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g=="],
+
+ "@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.1", "", {}, "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg=="],
+
+ "@protobufjs/fetch": ["@protobufjs/fetch@1.1.1", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1" } }, "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw=="],
+
+ "@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="],
+
+ "@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="],
+
+ "@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="],
+
+ "@protobufjs/utf8": ["@protobufjs/utf8@1.1.1", "", {}, "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg=="],
+
+ "@slack/logger": ["@slack/logger@4.0.1", "", { "dependencies": { "@types/node": ">=18" } }, "sha512-6cmdPrV/RYfd2U0mDGiMK8S7OJqpCTm7enMLRR3edccsPX8j7zXTLnaEF4fhxxJJTAIOil6+qZrnUPTuaLvwrQ=="],
+
+ "@slack/socket-mode": ["@slack/socket-mode@2.0.7", "", { "dependencies": { "@slack/logger": "^4.0.1", "@slack/web-api": "^7.15.0", "@types/node": ">=18", "@types/ws": "^8", "eventemitter3": "^5", "ws": "^8" } }, "sha512-qYy07je71WnEHgRwmw12DlAnZLi5HXmdlI2WUzUK2LH/rYXQpP6uEg462S5CwfE8FoCKUdIigHtYnOOfzZH1lQ=="],
+
+ "@slack/types": ["@slack/types@2.21.1", "", {}, "sha512-I8vmSjNYWsaxuWPx6dz4yeh0h7vRBWbgAMK14LEmblbZ404BtrPbXs6jDPx4cYgGf8msDGF4A9opLZBu21FViQ=="],
+
+ "@slack/web-api": ["@slack/web-api@7.17.0", "", { "dependencies": { "@slack/logger": "^4.0.1", "@slack/types": "^2.21.0", "@types/node": ">=18", "@types/retry": "0.12.0", "axios": "^1.16.0", "eventemitter3": "^5.0.1", "form-data": "^4.0.4", "is-electron": "2.2.2", "is-stream": "^2", "p-queue": "^6", "p-retry": "^4", "retry": "^0.13.1" } }, "sha512-jejr34a8B4L5AS713wOAx1LAqNkW16HVMDEa6sYBvFDc/llUBl8hXaiI4BwF+Al+Sug19Vn2O7iokTVIhVvZ1Q=="],
+
+ "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
+
+ "@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="],
+
+ "@types/retry": ["@types/retry@0.12.0", "", {}, "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA=="],
+
+ "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
+
+ "agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="],
+
+ "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
+
+ "axios": ["axios@1.13.6", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ=="],
+
+ "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
+
+ "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
+
+ "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
+
+ "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
+
+ "cron-parser": ["cron-parser@5.5.0", "", { "dependencies": { "luxon": "^3.7.1" } }, "sha512-oML4lKUXxizYswqmxuOCpgFS8BNUJpIu6k/2HVHyaL8Ynnf3wdf9tkns0yRdJLSIjkJ+b0DXHMZEHGpMwjnPww=="],
+
+ "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
+
+ "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
+
+ "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
+
+ "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
+
+ "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
+
+ "es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="],
+
+ "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
+
+ "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="],
+
+ "follow-redirects": ["follow-redirects@1.16.0", "", { "peerDependencies": { "debug": "*" }, "optionalPeers": ["debug"] }, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="],
+
+ "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
+
+ "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
+
+ "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
+
+ "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
+
+ "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
+
+ "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
+
+ "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
+
+ "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="],
+
+ "https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="],
+
+ "is-electron": ["is-electron@2.2.2", "", {}, "sha512-FO/Rhvz5tuw4MCWkpMzHFKWD2LsfHzIb7i6MdPYZ/KW7AlxawyLkqdy+jPZP1WubqEADE3O4FUENlJHDfQASRg=="],
+
+ "is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
+
+ "lodash.identity": ["lodash.identity@3.0.0", "", {}, "sha512-AupTIzdLQxJS5wIYUQlgGyk2XRTfGXA+MCghDHqZk0pzUNYvd3EESS6dkChNauNYVIutcb0dfHw1ri9Q1yPV8Q=="],
+
+ "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
+
+ "lodash.pickby": ["lodash.pickby@4.6.0", "", {}, "sha512-AZV+GsS/6ckvPOVQPXSiFFacKvKB4kOQu6ynt9wz0F3LO4R9Ij4K1ddYsIytDpSgLz88JHd9P+oaLeej5/Sl7Q=="],
+
+ "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
+
+ "luxon": ["luxon@3.7.2", "", {}, "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew=="],
+
+ "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
+
+ "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
+
+ "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
+
+ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
+
+ "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
+
+ "p-finally": ["p-finally@1.0.0", "", {}, "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow=="],
+
+ "p-queue": ["p-queue@6.6.2", "", { "dependencies": { "eventemitter3": "^4.0.4", "p-timeout": "^3.2.0" } }, "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ=="],
+
+ "p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="],
+
+ "p-timeout": ["p-timeout@3.2.0", "", { "dependencies": { "p-finally": "^1.0.0" } }, "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg=="],
+
+ "prettier": ["prettier@3.8.4", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q=="],
+
+ "protobufjs": ["protobufjs@7.6.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw=="],
+
+ "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
+
+ "qs": ["qs@6.15.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw=="],
+
+ "retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="],
+
+ "side-channel": ["side-channel@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4", "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="],
+
+ "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="],
+
+ "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
+
+ "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
+
+ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
+
+ "undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
+
+ "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="],
+
+ "@slack/web-api/axios": ["axios@1.17.0", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.5", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, "sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw=="],
+
+ "p-queue/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="],
+
+ "@slack/web-api/axios/proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="],
+ }
+}
diff --git a/backend/bunfig.toml b/backend/bunfig.toml
new file mode 100644
index 0000000..3853444
--- /dev/null
+++ b/backend/bunfig.toml
@@ -0,0 +1,5 @@
+# Bun test runner configuration for the AgentForge backend.
+[test]
+root = "tests"
+coverageSkipTestFiles = true
+coveragePathIgnorePatterns = ["node_modules", "tests"]
diff --git a/backend/package.json b/backend/package.json
new file mode 100644
index 0000000..f259a49
--- /dev/null
+++ b/backend/package.json
@@ -0,0 +1,27 @@
+{
+ "name": "agentforge-backend",
+ "version": "1.0.0",
+ "description": "AgentForge task board backend (Bun + TypeScript)",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "start": "bun taskboard.ts",
+ "check": "bun run typecheck && bun run format:check && bun test --coverage",
+ "test": "bun test",
+ "typecheck": "bunx tsc --noEmit",
+ "format": "prettier --write \"taskboard.ts\" \"src/**/*.ts\" \"tests/**/*.ts\"",
+ "format:check": "prettier --check \"taskboard.ts\" \"src/**/*.ts\" \"tests/**/*.ts\"",
+ "compile": "bun build --compile taskboard.ts --outfile ../taskboard-electron/resources/taskboard"
+ },
+ "dependencies": {
+ "@larksuiteoapi/node-sdk": "^1.55.0",
+ "@slack/socket-mode": "^2.0.4",
+ "@slack/web-api": "^7.13.0",
+ "cron-parser": "^5.4.0"
+ },
+ "devDependencies": {
+ "@types/bun": "^1.3.5",
+ "prettier": "^3.8.4",
+ "typescript": "^5.9.3"
+ }
+}
diff --git a/backend/src/api.ts b/backend/src/api.ts
new file mode 100644
index 0000000..0933b52
--- /dev/null
+++ b/backend/src/api.ts
@@ -0,0 +1,1388 @@
+import crypto from "node:crypto";
+import fs from "node:fs";
+import os from "node:os";
+import { CronExpressionParser } from "cron-parser";
+
+import { MessageBus } from "./bus.ts";
+import type { TaskDB } from "./db.ts";
+import type { TaskScheduler } from "./scheduler.ts";
+import {
+ DEFAULT_AGENT,
+ DEFAULT_TIMEOUT_SECONDS,
+ HeartbeatScheduleType,
+ ScheduleType,
+ makeHeartbeat,
+ makeTask,
+ type Heartbeat,
+ type Task,
+} from "./types.ts";
+import { dateToLocalIso } from "./util.ts";
+import { FeishuChannel } from "./channels/feishu.ts";
+import { SlackChannel } from "./channels/slack.ts";
+import {
+ create_telegram_channel,
+ type TelegramChannel,
+} from "./channels/telegram.ts";
+import { WeixinChannel } from "./channels/weixin.ts";
+
+type Row = Record;
+
+export interface ApiContext {
+ db: TaskDB;
+ scheduler: TaskScheduler;
+ bus: MessageBus;
+ telegram_channel: TelegramChannel | null;
+ slack_channel: SlackChannel | null;
+ weixin_channel: WeixinChannel | null;
+ feishu_channel: FeishuChannel | null;
+}
+
+const CSRF_TOKEN = crypto.randomBytes(32).toString("hex");
+const MAX_BODY_SIZE = 10 * 1024 * 1024;
+
+function isAllowedOrigin(origin: string): boolean {
+ if (origin === "null") return true;
+ if (!origin) return true;
+ return (
+ origin === "http://localhost" || origin.startsWith("http://localhost:")
+ );
+}
+
+function corsHeaders(origin: string): Headers {
+ const headers = new Headers({
+ "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
+ "Access-Control-Allow-Headers": "Content-Type, X-CSRF-Token",
+ });
+ if (isAllowedOrigin(origin)) {
+ headers.set("Access-Control-Allow-Origin", origin);
+ headers.set("Vary", "Origin");
+ }
+ return headers;
+}
+
+function jsonResponse(data: unknown, status = 200, origin = ""): Response {
+ const headers = corsHeaders(origin);
+ headers.set("Content-Type", "application/json");
+ return new Response(JSON.stringify(data), { status, headers });
+}
+
+function timingSafeEqual(a: string, b: string): boolean {
+ const left = Buffer.from(a);
+ const right = Buffer.from(b);
+ return left.length === right.length && crypto.timingSafeEqual(left, right);
+}
+
+function checkCsrf(req: Request): boolean {
+ const origin = req.headers.get("Origin") ?? "";
+ if (!origin) return true;
+ return timingSafeEqual(req.headers.get("X-CSRF-Token") ?? "", CSRF_TOKEN);
+}
+
+async function readJsonBody(
+ req: Request,
+ origin: string,
+): Promise {
+ const rawLength = req.headers.get("Content-Length") ?? "0";
+ const length = Number.parseInt(rawLength, 10) || 0;
+ if (length > MAX_BODY_SIZE) {
+ void req.body?.cancel();
+ return jsonResponse({ error: "request body too large" }, 413, origin);
+ }
+ const raw = await req.text();
+ if (!raw) return {};
+ try {
+ const parsed = JSON.parse(raw);
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
+ return parsed as Row;
+ }
+ return {};
+ } catch {
+ return jsonResponse({ error: "invalid JSON body" }, 400, origin);
+ }
+}
+
+function idAt(path: string, index = 3): number | null {
+ const raw = path.split("/")[index];
+ if (!raw || !/^\d+$/.test(raw)) return null;
+ return Number.parseInt(raw, 10);
+}
+
+function asBool(value: unknown): boolean {
+ if (typeof value === "boolean") return value;
+ if (typeof value === "string") return value.toLowerCase() === "true";
+ return Boolean(value);
+}
+
+function asString(value: unknown, fallback = ""): string {
+ if (value === null || value === undefined) return fallback;
+ return String(value);
+}
+
+function parseJsonList(value: unknown): any[] {
+ if (Array.isArray(value)) return value;
+ if (typeof value === "string") {
+ try {
+ const parsed = JSON.parse(value);
+ return Array.isArray(parsed) ? parsed : [];
+ } catch {
+ return [];
+ }
+ }
+ return [];
+}
+
+function cronValid(expr: string): boolean {
+ try {
+ CronExpressionParser.parse(expr);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+function cronNextIso(expr: string): string {
+ return dateToLocalIso(CronExpressionParser.parse(expr).next().toDate());
+}
+
+function ensureWorkingDir(
+ workingDir: string,
+ missingMessage: string,
+): Row | null {
+ if (workingDir && workingDir !== ".") {
+ const expanded =
+ workingDir === "~"
+ ? os.homedir()
+ : workingDir.replace(/^~\//, `${os.homedir()}/`);
+ if (!fs.existsSync(expanded) || !fs.statSync(expanded).isDirectory()) {
+ return { error: missingMessage, field: "working_dir" };
+ }
+ }
+ return null;
+}
+
+function dependencyList(
+ value: unknown,
+ forceInject = false,
+): Array<{ task_id: number; inject_result: boolean }> {
+ const deps: Array<{ task_id: number; inject_result: boolean }> = [];
+ if (!Array.isArray(value)) return deps;
+ for (const item of value) {
+ if (Number.isInteger(item)) {
+ deps.push({ task_id: item, inject_result: forceInject });
+ } else if (item && typeof item === "object" && "task_id" in item) {
+ deps.push({
+ task_id: Number((item as Row)["task_id"]),
+ inject_result: forceInject || Boolean((item as Row)["inject_result"]),
+ });
+ }
+ }
+ return deps.filter((d) => Number.isInteger(d.task_id));
+}
+
+function attachDependencyMetadata(db: TaskDB, task: Row): Row {
+ const tid = Number(task["id"]);
+ return {
+ ...task,
+ dependencies: db.get_dependencies(tid),
+ dependents: db.get_dependents(tid).map((d) => d["task_id"]),
+ };
+}
+
+function taskOutputPayload(ctx: ApiContext, taskId: number): Row {
+ const isRunning = ctx.scheduler._live_output.has(taskId);
+ if (isRunning) {
+ return {
+ output: ctx.scheduler._live_output.get(taskId) ?? "",
+ is_running: true,
+ };
+ }
+ const runs = ctx.db.get_task_runs(taskId, 1);
+ return { output: runs[0]?.["raw_output"] ?? "", is_running: false };
+}
+
+function taskMessages(ctx: ApiContext, taskId: number): Row[] {
+ const messages: Row[] = [];
+ const runs = ctx.db.get_task_runs(taskId, 50);
+ for (const run of [...runs].reverse()) {
+ for (const rawLine of String(run["raw_output"] ?? "").split(/\r?\n/)) {
+ const line = rawLine.trim();
+ if (!line) continue;
+ try {
+ const event = JSON.parse(line) as Row;
+ const content = event["message"]?.["content"] ?? [];
+ if (event["type"] === "user") {
+ const text = content
+ .map((c: unknown) =>
+ typeof c === "string"
+ ? c
+ : c && typeof c === "object" && (c as Row)["type"] === "text"
+ ? (c as Row)["text"]
+ : "",
+ )
+ .join("");
+ if (text.trim())
+ messages.push({ role: "user", text, run_id: run["id"] });
+ } else if (event["type"] === "assistant") {
+ const text = Array.isArray(content)
+ ? content
+ .map((c) =>
+ c && typeof c === "object" && c["type"] === "text"
+ ? c["text"]
+ : "",
+ )
+ .join("")
+ : "";
+ if (text.trim())
+ messages.push({ role: "assistant", text, run_id: run["id"] });
+ }
+ } catch {
+ // Keep the Python route's tolerant "skip malformed NDJSON" behavior.
+ }
+ }
+ }
+ return messages;
+}
+
+function validateHeartbeatPayload(
+ ctx: ApiContext,
+ body: Row,
+ existing: Row | null = null,
+): { heartbeat?: Heartbeat; response?: ResponseData } {
+ const name = body["name"] ?? existing?.["name"] ?? "Untitled heartbeat";
+ const checkPrompt = body["check_prompt"] ?? existing?.["check_prompt"] ?? "";
+ if (!String(checkPrompt).trim()) {
+ return {
+ response: [
+ { error: "check_prompt cannot be empty", field: "check_prompt" },
+ 400,
+ ],
+ };
+ }
+
+ const workingDir = asString(
+ body["working_dir"] ?? existing?.["working_dir"] ?? ".",
+ );
+ const workingDirError = ensureWorkingDir(
+ workingDir,
+ `working_dir does not exist or is not a directory: ${workingDir}`,
+ );
+ if (workingDirError) return { response: [workingDirError, 400] };
+
+ const scheduleType = asString(
+ body["schedule_type"] ?? existing?.["schedule_type"] ?? "interval",
+ );
+ if (
+ scheduleType !== HeartbeatScheduleType.CRON &&
+ scheduleType !== HeartbeatScheduleType.INTERVAL
+ ) {
+ return {
+ response: [
+ {
+ error: `invalid heartbeat schedule_type: ${scheduleType}`,
+ field: "schedule_type",
+ },
+ 400,
+ ],
+ };
+ }
+
+ let cronExpr = body["cron_expr"] ?? existing?.["cron_expr"] ?? null;
+ let intervalSeconds =
+ body["interval_seconds"] ?? existing?.["interval_seconds"] ?? null;
+ if (scheduleType === HeartbeatScheduleType.CRON) {
+ if (!String(cronExpr ?? "").trim()) {
+ return {
+ response: [
+ {
+ error: "cron_expr is required for cron heartbeat",
+ field: "cron_expr",
+ },
+ 400,
+ ],
+ };
+ }
+ if (!cronValid(String(cronExpr))) {
+ return {
+ response: [
+ { error: `invalid cron expression: ${cronExpr}`, field: "cron_expr" },
+ 400,
+ ],
+ };
+ }
+ intervalSeconds = null;
+ } else {
+ const parsed = Number.parseInt(String(intervalSeconds ?? ""), 10);
+ if (!Number.isInteger(parsed) || parsed <= 0) {
+ return {
+ response: [
+ {
+ error: "interval_seconds must be a positive integer",
+ field: "interval_seconds",
+ },
+ 400,
+ ],
+ };
+ }
+ intervalSeconds = parsed;
+ cronExpr = null;
+ }
+
+ const cooldownSeconds = Number.parseInt(
+ String(body["cooldown_seconds"] ?? existing?.["cooldown_seconds"] ?? 0),
+ 10,
+ );
+ if (!Number.isInteger(cooldownSeconds)) {
+ return {
+ response: [
+ {
+ error: "cooldown_seconds must be an integer",
+ field: "cooldown_seconds",
+ },
+ 400,
+ ],
+ };
+ }
+ if (cooldownSeconds < 0) {
+ return {
+ response: [
+ {
+ error: "cooldown_seconds cannot be negative",
+ field: "cooldown_seconds",
+ },
+ 400,
+ ],
+ };
+ }
+
+ const heartbeat = makeHeartbeat({
+ id: existing?.["id"] ?? null,
+ name: String(name),
+ enabled: asBool(body["enabled"] ?? existing?.["enabled"] ?? true),
+ working_dir: workingDir,
+ schedule_type: scheduleType,
+ cron_expr: cronExpr === null ? null : String(cronExpr),
+ interval_seconds: intervalSeconds === null ? null : Number(intervalSeconds),
+ check_prompt: String(checkPrompt),
+ action_prompt_template: String(
+ body["action_prompt_template"] ??
+ existing?.["action_prompt_template"] ??
+ "",
+ ),
+ default_agent: String(
+ body["default_agent"] ??
+ existing?.["default_agent"] ??
+ ctx.db.get_setting("default_agent", DEFAULT_AGENT),
+ ),
+ cooldown_seconds: cooldownSeconds,
+ next_run_at: existing?.["next_run_at"] ?? null,
+ last_tick_at: existing?.["last_tick_at"] ?? null,
+ last_decision: existing?.["last_decision"] ?? null,
+ last_error: existing?.["last_error"] ?? null,
+ last_triggered_at: existing?.["last_triggered_at"] ?? null,
+ last_dedupe_key: existing?.["last_dedupe_key"] ?? null,
+ });
+ heartbeat.next_run_at = ctx.db._compute_heartbeat_next_run_at(
+ heartbeat,
+ new Date(),
+ );
+ return { heartbeat };
+}
+
+type ResponseData = [unknown, number?];
+
+function weixinStatus(ctx: ApiContext): Row {
+ const snapshot = ctx.weixin_channel?.get_status_snapshot?.() ?? {};
+ const runtimeAccount = asString(snapshot["account_id"]);
+ const configuredAccount = ctx.db.get_setting("weixin_account_id", "") ?? "";
+ return {
+ enabled: ctx.db.get_setting("weixin_enabled", "false") === "true",
+ configured: Boolean(snapshot["configured"]),
+ running: Boolean(ctx.weixin_channel?._running),
+ default_working_dir: ctx.db.get_setting("weixin_default_working_dir", "~"),
+ base_url: ctx.db.get_setting(
+ "weixin_base_url",
+ "https://ilinkai.weixin.qq.com",
+ ),
+ account_id: runtimeAccount || configuredAccount,
+ login_status: snapshot["login_status"] ?? "idle",
+ qr_code_url: snapshot["qr_code_url"] ?? "",
+ last_error: snapshot["last_error"] ?? "",
+ user_id: snapshot["user_id"] ?? "",
+ };
+}
+
+function channelsStatus(ctx: ApiContext): Row {
+ const tgToken =
+ ctx.db.get_setting("telegram_bot_token", "") ||
+ process.env.TELEGRAM_BOT_TOKEN ||
+ "";
+ const slBot =
+ ctx.db.get_setting("slack_bot_token", "") ||
+ process.env.SLACK_BOT_TOKEN ||
+ "";
+ const slApp =
+ ctx.db.get_setting("slack_app_token", "") ||
+ process.env.SLACK_APP_TOKEN ||
+ "";
+ return {
+ telegram: {
+ enabled: ctx.db.get_setting("telegram_enabled", "false") === "true",
+ configured: Boolean(tgToken),
+ running: Boolean(ctx.telegram_channel?._running),
+ default_working_dir: ctx.db.get_setting(
+ "telegram_default_working_dir",
+ "~",
+ ),
+ default_chat_id: ctx.db.get_setting("telegram_default_chat_id", ""),
+ allowed_users: ctx.db.get_setting("telegram_allowed_users", ""),
+ },
+ slack: {
+ enabled: ctx.db.get_setting("slack_enabled", "false") === "true",
+ configured: Boolean(slBot && slApp),
+ running: Boolean(ctx.slack_channel?._running),
+ default_working_dir: ctx.db.get_setting("slack_default_working_dir", "~"),
+ default_channel: ctx.db.get_setting("slack_default_channel", ""),
+ default_user: ctx.db.get_setting("slack_default_user", ""),
+ },
+ weixin: weixinStatus(ctx),
+ feishu: {
+ configured: ctx.db.get_setting("feishu_enabled", "false") === "true",
+ running: Boolean(ctx.feishu_channel?._running),
+ },
+ };
+}
+
+async function restartChannels(ctx: ApiContext, body: Row): Promise {
+ if (ctx.telegram_channel) {
+ ctx.telegram_channel.stop();
+ ctx.telegram_channel = null;
+ }
+ const tgEnabled =
+ (body["telegram_enabled"] ??
+ ctx.db.get_setting("telegram_enabled", "false")) === "true";
+ if (tgEnabled) {
+ const token =
+ ctx.db.get_setting("telegram_bot_token", "") ||
+ process.env.TELEGRAM_BOT_TOKEN ||
+ "";
+ const allowed =
+ ctx.db.get_setting("telegram_allowed_users", "") ||
+ process.env.TELEGRAM_ALLOWED_USERS ||
+ "";
+ if (token) {
+ ctx.telegram_channel = create_telegram_channel(
+ ctx.db,
+ ctx.scheduler,
+ ctx.bus,
+ token,
+ allowed,
+ );
+ ctx.telegram_channel?.start();
+ }
+ }
+
+ if (ctx.slack_channel) {
+ ctx.slack_channel.stop();
+ ctx.slack_channel = null;
+ }
+ const slEnabled =
+ (body["slack_enabled"] ?? ctx.db.get_setting("slack_enabled", "false")) ===
+ "true";
+ if (slEnabled) {
+ const botToken =
+ ctx.db.get_setting("slack_bot_token", "") ||
+ process.env.SLACK_BOT_TOKEN ||
+ "";
+ const appToken =
+ ctx.db.get_setting("slack_app_token", "") ||
+ process.env.SLACK_APP_TOKEN ||
+ "";
+ if (botToken && appToken) {
+ ctx.slack_channel = new SlackChannel(
+ ctx.bus,
+ ctx.db,
+ ctx.scheduler,
+ botToken,
+ appToken,
+ );
+ await ctx.slack_channel.start();
+ }
+ }
+
+ if (ctx.weixin_channel) {
+ ctx.weixin_channel.stop();
+ ctx.weixin_channel = null;
+ }
+ const wxEnabled =
+ (body["weixin_enabled"] ??
+ ctx.db.get_setting("weixin_enabled", "false")) === "true";
+ if (wxEnabled) {
+ ctx.weixin_channel = new WeixinChannel(ctx.bus, ctx.db, ctx.scheduler);
+ ctx.weixin_channel.start();
+ }
+
+ if (ctx.feishu_channel) {
+ ctx.feishu_channel.stop();
+ ctx.feishu_channel = null;
+ }
+ const fsEnabled =
+ (body["feishu_enabled"] ??
+ ctx.db.get_setting("feishu_enabled", "false")) === "true";
+ if (fsEnabled) {
+ ctx.feishu_channel = new FeishuChannel(ctx.bus, ctx.db, ctx.scheduler);
+ ctx.feishu_channel.start();
+ }
+}
+
+async function handleGet(
+ ctx: ApiContext,
+ req: Request,
+ url: URL,
+ origin: string,
+): Promise {
+ const path = url.pathname;
+
+ if (path === "/api/heartbeats") {
+ return jsonResponse(ctx.db.get_all_heartbeats(), 200, origin);
+ }
+ if (
+ path.startsWith("/api/heartbeats/") &&
+ path.includes("/ticks/") &&
+ path.endsWith("/output")
+ ) {
+ const hid = idAt(path);
+ const tickId = idAt(path, 5);
+ if (hid === null || tickId === null)
+ return jsonResponse({ error: "not found" }, 404, origin);
+ const tick = ctx.db.get_heartbeat_tick(hid, tickId);
+ if (!tick) return jsonResponse({ error: "not found" }, 404, origin);
+ const output =
+ ctx.scheduler._live_heartbeat_output.get(tickId) ??
+ tick["raw_output"] ??
+ "";
+ return jsonResponse(
+ { output, is_running: ctx.scheduler._live_heartbeat_output.has(tickId) },
+ 200,
+ origin,
+ );
+ }
+ if (path.startsWith("/api/heartbeats/") && path.endsWith("/ticks")) {
+ const hid = idAt(path);
+ if (hid === null) return jsonResponse({ error: "not found" }, 404, origin);
+ const limit = Number.parseInt(url.searchParams.get("limit") ?? "50", 10);
+ return jsonResponse(
+ { ticks: ctx.db.get_heartbeat_ticks(hid, limit) },
+ 200,
+ origin,
+ );
+ }
+ if (path.startsWith("/api/heartbeats/")) {
+ const hid = idAt(path);
+ if (hid === null) return jsonResponse({ error: "not found" }, 404, origin);
+ const heartbeat = ctx.db.get_heartbeat(hid);
+ return heartbeat
+ ? jsonResponse(heartbeat, 200, origin)
+ : jsonResponse({ error: "not found" }, 404, origin);
+ }
+
+ if (path === "/api/tasks") {
+ return jsonResponse(
+ ctx.db.get_all_tasks().map((t) => attachDependencyMetadata(ctx.db, t)),
+ 200,
+ origin,
+ );
+ }
+ if (path.startsWith("/api/tasks/") && path.endsWith("/runs")) {
+ const tid = idAt(path);
+ return tid === null
+ ? jsonResponse({ error: "not found" }, 404, origin)
+ : jsonResponse(ctx.db.get_task_runs(tid), 200, origin);
+ }
+ if (path.startsWith("/api/tasks/") && path.endsWith("/output")) {
+ const tid = idAt(path);
+ return tid === null
+ ? jsonResponse({ error: "not found" }, 404, origin)
+ : jsonResponse(taskOutputPayload(ctx, tid), 200, origin);
+ }
+ if (path.startsWith("/api/tasks/") && path.endsWith("/events")) {
+ const tid = idAt(path);
+ if (tid === null) return jsonResponse({ error: "not found" }, 404, origin);
+ const limit = Number.parseInt(url.searchParams.get("limit") ?? "1000", 10);
+ const offset = Number.parseInt(url.searchParams.get("offset") ?? "0", 10);
+ const events = ctx.db.get_output_events(tid, limit, offset);
+ return jsonResponse({ events, total: events.length }, 200, origin);
+ }
+ if (path.startsWith("/api/tasks/") && path.endsWith("/messages")) {
+ const tid = idAt(path);
+ return tid === null
+ ? jsonResponse({ error: "not found" }, 404, origin)
+ : jsonResponse(taskMessages(ctx, tid), 200, origin);
+ }
+ if (path.startsWith("/api/tasks/") && path.endsWith("/dependencies")) {
+ const tid = idAt(path);
+ return tid === null
+ ? jsonResponse({ error: "not found" }, 404, origin)
+ : jsonResponse(ctx.db.get_dependencies(tid), 200, origin);
+ }
+ if (path.startsWith("/api/tasks/") && path.endsWith("/dependents")) {
+ const tid = idAt(path);
+ return tid === null
+ ? jsonResponse({ error: "not found" }, 404, origin)
+ : jsonResponse(ctx.db.get_dependents(tid), 200, origin);
+ }
+ if (path.startsWith("/api/dag/")) {
+ const dagId = decodeURIComponent(path.slice("/api/dag/".length));
+ const tasks = ctx.db
+ .get_dag_tasks(dagId)
+ .map((t) => attachDependencyMetadata(ctx.db, t));
+ return jsonResponse(tasks, 200, origin);
+ }
+ if (path.startsWith("/api/tasks/")) {
+ const tid = idAt(path);
+ const task = tid === null ? null : ctx.db.get_task(tid);
+ return task
+ ? jsonResponse(attachDependencyMetadata(ctx.db, task), 200, origin)
+ : jsonResponse({ error: "not found" }, 404, origin);
+ }
+
+ if (path === "/api/skill-patterns") {
+ return jsonResponse(
+ {
+ patterns: ctx.db.get_skill_patterns(),
+ sweep: ctx.scheduler.skill_sweep_status(),
+ },
+ 200,
+ origin,
+ );
+ }
+ if (path === "/api/skills") {
+ return jsonResponse({ skills: ctx.db.get_skills() }, 200, origin);
+ }
+ if (path.startsWith("/api/skills/") && path.endsWith("/content")) {
+ const sid = idAt(path);
+ const skill = sid === null ? null : ctx.db.get_skill(sid);
+ if (!skill) return jsonResponse({ error: "not found" }, 404, origin);
+ let content: string;
+ try {
+ content = fs.readFileSync(String(skill["path"]), "utf8");
+ } catch (e) {
+ content = `(无法读取 SKILL.md:${e})`;
+ }
+ return jsonResponse({ content, path: skill["path"], skill }, 200, origin);
+ }
+
+ if (path === "/api/csrf-token")
+ return jsonResponse({ csrf_token: CSRF_TOKEN }, 200, origin);
+ if (path === "/api/health")
+ return jsonResponse(
+ { status: "ok", tasks: ctx.db.get_all_tasks().length },
+ 200,
+ origin,
+ );
+ if (path === "/api/settings") {
+ return jsonResponse(
+ {
+ default_agent: ctx.db.get_setting("default_agent", DEFAULT_AGENT),
+ timeout: Number.parseInt(
+ ctx.db.get_setting("timeout", String(DEFAULT_TIMEOUT_SECONDS)) ??
+ String(DEFAULT_TIMEOUT_SECONDS),
+ 10,
+ ),
+ skill_library_enabled:
+ ctx.db.get_setting("skill_library_enabled", "0") === "1",
+ skill_sweep_agent: ctx.db.get_setting(
+ "skill_sweep_agent",
+ DEFAULT_AGENT,
+ ),
+ skill_sweep_cron: ctx.db.get_setting("skill_sweep_cron", "0 3 * * *"),
+ },
+ 200,
+ origin,
+ );
+ }
+ if (path === "/api/feishu/settings") {
+ return jsonResponse(
+ {
+ feishu_app_id: ctx.db.get_setting("feishu_app_id", ""),
+ feishu_app_secret: ctx.db.get_setting("feishu_app_secret", ""),
+ feishu_default_chat_id: ctx.db.get_setting(
+ "feishu_default_chat_id",
+ "",
+ ),
+ feishu_default_working_dir: ctx.db.get_setting(
+ "feishu_default_working_dir",
+ "~",
+ ),
+ feishu_enabled: ctx.db.get_setting("feishu_enabled", "false"),
+ },
+ 200,
+ origin,
+ );
+ }
+ if (path === "/api/channels/status") {
+ return jsonResponse(channelsStatus(ctx), 200, origin);
+ }
+
+ return jsonResponse({ error: "not found" }, 404, origin);
+}
+
+async function handlePost(
+ ctx: ApiContext,
+ req: Request,
+ url: URL,
+ origin: string,
+): Promise {
+ const bodyOrResponse = await readJsonBody(req, origin);
+ if (bodyOrResponse instanceof Response) return bodyOrResponse;
+ const body = bodyOrResponse;
+ const path = url.pathname;
+
+ if (path === "/api/heartbeats") {
+ const validated = validateHeartbeatPayload(ctx, body);
+ if (validated.response)
+ return jsonResponse(
+ validated.response[0],
+ validated.response[1] ?? 200,
+ origin,
+ );
+ const id = ctx.db.add_heartbeat(validated.heartbeat!);
+ return jsonResponse({ id, status: "created" }, 201, origin);
+ }
+ if (path.startsWith("/api/heartbeats/") && path.endsWith("/run-now")) {
+ const hid = idAt(path);
+ if (hid === null) return jsonResponse({ error: "not found" }, 404, origin);
+ try {
+ ctx.scheduler.trigger_heartbeat_now(hid);
+ } catch (e) {
+ const msg = e instanceof Error ? e.message : String(e);
+ return jsonResponse(
+ { error: msg },
+ msg.includes("not found") ? 404 : 409,
+ origin,
+ );
+ }
+ return jsonResponse({ status: "scheduled" }, 200, origin);
+ }
+ if (path.startsWith("/api/heartbeats/") && path.endsWith("/pause")) {
+ const hid = idAt(path);
+ if (hid === null) return jsonResponse({ error: "not found" }, 404, origin);
+ try {
+ ctx.scheduler.pause_heartbeat(hid);
+ } catch (e) {
+ return jsonResponse(
+ { error: e instanceof Error ? e.message : String(e) },
+ 404,
+ origin,
+ );
+ }
+ return jsonResponse({ status: "paused" }, 200, origin);
+ }
+ if (path.startsWith("/api/heartbeats/") && path.endsWith("/resume")) {
+ const hid = idAt(path);
+ if (hid === null) return jsonResponse({ error: "not found" }, 404, origin);
+ try {
+ ctx.scheduler.resume_heartbeat(hid);
+ } catch (e) {
+ return jsonResponse(
+ { error: e instanceof Error ? e.message : String(e) },
+ 404,
+ origin,
+ );
+ }
+ return jsonResponse({ status: "resumed" }, 200, origin);
+ }
+
+ if (path === "/api/skills/sweep") {
+ const started = ctx.scheduler.trigger_skill_sweep(
+ body["agent"] ?? null,
+ Boolean(body["full"] ?? true),
+ );
+ return started
+ ? jsonResponse({ status: "started" }, 200, origin)
+ : jsonResponse({ error: "sweep already running" }, 409, origin);
+ }
+ if (path.startsWith("/api/skill-patterns/") && path.endsWith("/draft")) {
+ const pid = idAt(path);
+ if (
+ pid === null ||
+ !ctx.scheduler.trigger_skill_draft(pid, body["agent"] ?? null)
+ ) {
+ return jsonResponse({ error: "pattern not found" }, 404, origin);
+ }
+ return jsonResponse({ status: "drafting" }, 200, origin);
+ }
+ if (path.startsWith("/api/skill-patterns/") && path.endsWith("/approve")) {
+ const pid = idAt(path);
+ if (pid === null)
+ return jsonResponse({ error: "pattern not found" }, 404, origin);
+ const draft = ctx.db.get_skill_draft(pid);
+ try {
+ const skill = ctx.scheduler.approve_skill(
+ pid,
+ String(body["name"] ?? draft?.["name"] ?? ""),
+ String(body["description"] ?? draft?.["description"] ?? ""),
+ String(body["body"] ?? draft?.["body"] ?? ""),
+ );
+ return jsonResponse({ status: "approved", skill }, 200, origin);
+ } catch (e) {
+ const msg = e instanceof Error ? e.message : String(e);
+ return jsonResponse(
+ { error: msg },
+ msg.includes("not found") ? 404 : 400,
+ origin,
+ );
+ }
+ }
+ if (path.startsWith("/api/skill-patterns/") && path.endsWith("/dismiss")) {
+ const pid = idAt(path);
+ try {
+ if (pid === null) throw new Error("pattern not found");
+ ctx.scheduler.dismiss_skill_pattern(pid);
+ } catch (e) {
+ return jsonResponse(
+ { error: e instanceof Error ? e.message : String(e) },
+ 404,
+ origin,
+ );
+ }
+ return jsonResponse({ status: "dismissed" }, 200, origin);
+ }
+
+ if (path === "/api/tasks") {
+ const prompt = asString(body["prompt"]);
+ if (!prompt.trim())
+ return jsonResponse(
+ { error: "prompt cannot be empty", field: "prompt" },
+ 400,
+ origin,
+ );
+ const workingDir = asString(body["working_dir"], ".");
+ const dirError = ensureWorkingDir(
+ workingDir,
+ `working_dir does not exist or is not a directory: ${workingDir}`,
+ );
+ if (dirError) return jsonResponse(dirError, 400, origin);
+ const scheduleType = asString(body["schedule_type"], "immediate");
+ const cronExpr =
+ body["cron_expr"] === undefined ? null : asString(body["cron_expr"]);
+ if (scheduleType === "cron") {
+ if (!cronExpr?.trim())
+ return jsonResponse(
+ {
+ error: "cron_expr is required for cron schedule",
+ field: "cron_expr",
+ },
+ 400,
+ origin,
+ );
+ if (!cronValid(cronExpr))
+ return jsonResponse(
+ { error: `invalid cron expression: ${cronExpr}`, field: "cron_expr" },
+ 400,
+ origin,
+ );
+ }
+ const deps = dependencyList(
+ body["depends_on"],
+ Boolean(body["inject_result"]),
+ );
+ const task: Task = makeTask({
+ title: asString(body["title"], "Untitled"),
+ prompt,
+ working_dir: workingDir,
+ schedule_type: scheduleType as ScheduleType,
+ cron_expr: cronExpr,
+ delay_seconds: body["delay_seconds"] ?? null,
+ next_run_at: body["next_run_at"] ?? null,
+ max_runs: body["max_runs"] ?? null,
+ tags: asString(body["tags"]),
+ agent: asString(
+ body["agent"] ?? ctx.db.get_setting("default_agent", DEFAULT_AGENT),
+ DEFAULT_AGENT,
+ ),
+ prompt_images: parseJsonList(body["prompt_images"]),
+ image_paths: parseJsonList(body["image_paths"]).map(String),
+ dag_id: body["dag_id"] ?? null,
+ });
+ const id = ctx.scheduler.submit_task(task, deps);
+ return jsonResponse({ id, status: "created" }, 201, origin);
+ }
+
+ if (path === "/api/settings") {
+ for (const [key, value] of Object.entries(body))
+ ctx.db.set_setting(key, String(value));
+ return jsonResponse({ status: "updated" }, 200, origin);
+ }
+ if (path === "/api/feishu/settings") {
+ for (const key of [
+ "feishu_app_id",
+ "feishu_app_secret",
+ "feishu_default_chat_id",
+ "feishu_default_working_dir",
+ "feishu_enabled",
+ ]) {
+ if (key in body) ctx.db.set_setting(key, String(body[key]));
+ }
+ await restartChannels(ctx, body);
+ return jsonResponse({ status: "updated" }, 200, origin);
+ }
+ if (path === "/api/channels/settings") {
+ const allowed = new Set([
+ "telegram_bot_token",
+ "telegram_allowed_users",
+ "telegram_default_working_dir",
+ "telegram_enabled",
+ "telegram_default_chat_id",
+ "slack_bot_token",
+ "slack_app_token",
+ "slack_default_working_dir",
+ "slack_default_channel",
+ "slack_default_user",
+ "slack_enabled",
+ "weixin_default_working_dir",
+ "weixin_base_url",
+ "weixin_account_id",
+ "weixin_enabled",
+ ]);
+ for (const [key, value] of Object.entries(body)) {
+ if (allowed.has(key)) ctx.db.set_setting(key, String(value));
+ }
+ await restartChannels(ctx, body);
+ return jsonResponse({ status: "updated" }, 200, origin);
+ }
+ if (path === "/api/channels/weixin/action") {
+ const action = asString(body["action"]).trim().toLowerCase();
+ if (!ctx.weixin_channel)
+ return jsonResponse({ error: "weixin channel not running" }, 400, origin);
+ if (action === "login" || action === "reconnect") {
+ ctx.weixin_channel.request_login();
+ return jsonResponse({ status: "ok", action }, 200, origin);
+ }
+ if (action === "logout") {
+ ctx.weixin_channel.request_logout();
+ return jsonResponse({ status: "ok", action }, 200, origin);
+ }
+ return jsonResponse({ error: "unsupported action" }, 400, origin);
+ }
+
+ if (path === "/api/dag") {
+ const taskDefs = Array.isArray(body["tasks"])
+ ? (body["tasks"] as Row[])
+ : [];
+ if (!taskDefs.length)
+ return jsonResponse({ error: "tasks list is required" }, 400, origin);
+ const dagId = asString(
+ body["dag_id"],
+ `dag-${Math.trunc(Date.now() / 1000)}`,
+ );
+ const refToId = new Map();
+ const results: Row = {};
+ for (const tdef of taskDefs) {
+ const ref = asString(tdef["ref"], String(refToId.size));
+ const dependsOn: Array<{ task_id: number; inject_result: boolean }> = [];
+ for (const depRef of Array.isArray(tdef["depends_on_refs"])
+ ? tdef["depends_on_refs"]
+ : []) {
+ const upstreamId = refToId.get(String(depRef));
+ if (upstreamId === undefined) {
+ return jsonResponse(
+ {
+ error: `ref '${depRef}' not found - declare tasks in topological order`,
+ },
+ 400,
+ origin,
+ );
+ }
+ dependsOn.push({
+ task_id: upstreamId,
+ inject_result: Boolean(tdef["inject_result"]),
+ });
+ }
+ const task = makeTask({
+ title: asString(tdef["title"], asString(tdef["prompt"]).slice(0, 60)),
+ prompt: asString(tdef["prompt"]),
+ working_dir: asString(tdef["working_dir"], "."),
+ schedule_type: asString(
+ tdef["schedule_type"],
+ "immediate",
+ ) as ScheduleType,
+ cron_expr: tdef["cron_expr"] ?? null,
+ delay_seconds: tdef["delay_seconds"] ?? null,
+ next_run_at: tdef["next_run_at"] ?? null,
+ max_runs: tdef["max_runs"] ?? null,
+ tags: asString(tdef["tags"]),
+ agent: asString(
+ tdef["agent"] ?? ctx.db.get_setting("default_agent", DEFAULT_AGENT),
+ DEFAULT_AGENT,
+ ),
+ prompt_images: parseJsonList(tdef["prompt_images"]),
+ dag_id: dagId,
+ });
+ const taskId = ctx.scheduler.submit_task(task, dependsOn);
+ refToId.set(ref, taskId);
+ results[ref] = taskId;
+ }
+ return jsonResponse({ dag_id: dagId, task_ids: results }, 201, origin);
+ }
+
+ if (path.startsWith("/api/tasks/") && path.endsWith("/dependencies")) {
+ const tid = idAt(path);
+ const depTaskId = Number(body["depends_on_task_id"]);
+ if (tid === null || !Number.isInteger(depTaskId))
+ return jsonResponse(
+ { error: "depends_on_task_id required" },
+ 400,
+ origin,
+ );
+ const task = ctx.db.get_task(tid);
+ const upstream = ctx.db.get_task(depTaskId);
+ if (!task || !upstream)
+ return jsonResponse({ error: "task not found" }, 404, origin);
+ const shouldBlock =
+ upstream["status"] !== "completed" &&
+ ["pending", "scheduled"].includes(task["status"]);
+ ctx.db.transaction(() => {
+ ctx.db.add_dependency(tid, depTaskId, Boolean(body["inject_result"]));
+ if (shouldBlock) ctx.db.update_task(tid, { status: "blocked" });
+ });
+ if (shouldBlock) ctx.scheduler._notify(tid);
+ return jsonResponse({ status: "added" }, 200, origin);
+ }
+ if (path.startsWith("/api/tasks/") && path.endsWith("/cancel")) {
+ const tid = idAt(path);
+ if (tid === null) return jsonResponse({ error: "not found" }, 404, origin);
+ ctx.scheduler.cancel_task(tid);
+ return jsonResponse({ status: "cancelled" }, 200, origin);
+ }
+ if (path.startsWith("/api/tasks/") && path.endsWith("/retry")) {
+ const tid = idAt(path);
+ if (tid === null) return jsonResponse({ error: "not found" }, 404, origin);
+ ctx.scheduler.retry_task(tid);
+ return jsonResponse({ status: "retrying" }, 200, origin);
+ }
+ if (path.startsWith("/api/tasks/") && path.endsWith("/respond")) {
+ const tid = idAt(path);
+ const task = tid === null ? null : ctx.db.get_task(tid);
+ if (!task || tid === null)
+ return jsonResponse({ error: "not found" }, 404, origin);
+ const answer = asString(body["answer"]);
+ ctx.db.update_task(tid, {
+ status: "pending",
+ prompt: answer,
+ answer,
+ question: null,
+ error: null,
+ });
+ return jsonResponse({ status: "responding" }, 200, origin);
+ }
+ if (path.startsWith("/api/tasks/") && path.endsWith("/resume")) {
+ const tid = idAt(path);
+ const task = tid === null ? null : ctx.db.get_task(tid);
+ const message = asString(body["message"]).trim();
+ if (!task || tid === null)
+ return jsonResponse({ error: "not found" }, 404, origin);
+ if (!message)
+ return jsonResponse({ error: "message required" }, 400, origin);
+ if (!task["session_id"])
+ return jsonResponse(
+ { error: "no session_id - cannot resume" },
+ 400,
+ origin,
+ );
+ ctx.db.update_task(tid, {
+ status: "pending",
+ prompt: message,
+ result: null,
+ error: null,
+ question: null,
+ });
+ return jsonResponse({ status: "resuming" }, 200, origin);
+ }
+
+ return jsonResponse({ error: "not found" }, 404, origin);
+}
+
+async function handlePut(
+ ctx: ApiContext,
+ req: Request,
+ url: URL,
+ origin: string,
+): Promise {
+ const bodyOrResponse = await readJsonBody(req, origin);
+ if (bodyOrResponse instanceof Response) return bodyOrResponse;
+ const body = bodyOrResponse;
+ const path = url.pathname;
+
+ if (path === "/api/settings") {
+ for (const [key, value] of Object.entries(body))
+ ctx.db.set_setting(key, String(value));
+ return jsonResponse({ status: "updated" }, 200, origin);
+ }
+ if (path.startsWith("/api/skills/")) {
+ const sid = idAt(path);
+ if (sid === null)
+ return jsonResponse({ error: "invalid skill id" }, 400, origin);
+ try {
+ const skill = ctx.scheduler.toggle_skill(
+ sid,
+ Boolean(body["enabled"] ?? true),
+ );
+ return jsonResponse({ status: "updated", skill }, 200, origin);
+ } catch (e) {
+ return jsonResponse(
+ { error: e instanceof Error ? e.message : String(e) },
+ 404,
+ origin,
+ );
+ }
+ }
+ if (path.startsWith("/api/heartbeats/") && path.split("/").length === 4) {
+ const hid = idAt(path);
+ if (hid === null)
+ return jsonResponse({ error: "invalid heartbeat id" }, 400, origin);
+ const existing = ctx.db.get_heartbeat(hid);
+ if (!existing) return jsonResponse({ error: "not found" }, 404, origin);
+ const validated = validateHeartbeatPayload(ctx, body, existing);
+ if (validated.response)
+ return jsonResponse(
+ validated.response[0],
+ validated.response[1] ?? 200,
+ origin,
+ );
+ const hb = validated.heartbeat!;
+ ctx.db.update_heartbeat(hid, {
+ name: hb.name,
+ enabled: hb.enabled ? 1 : 0,
+ working_dir: hb.working_dir,
+ schedule_type: hb.schedule_type,
+ cron_expr: hb.cron_expr,
+ interval_seconds: hb.interval_seconds,
+ check_prompt: hb.check_prompt,
+ action_prompt_template: hb.action_prompt_template,
+ default_agent: hb.default_agent,
+ cooldown_seconds: hb.cooldown_seconds,
+ next_run_at: hb.next_run_at,
+ });
+ return jsonResponse(ctx.db.get_heartbeat(hid), 200, origin);
+ }
+ if (path.startsWith("/api/tasks/") && path.split("/").length === 4) {
+ const tid = idAt(path);
+ if (tid === null)
+ return jsonResponse({ error: "invalid task id" }, 400, origin);
+ const task = ctx.db.get_task(tid);
+ if (!task) return jsonResponse({ error: "not found" }, 404, origin);
+ if (!["pending", "scheduled", "blocked"].includes(task["status"])) {
+ return jsonResponse(
+ {
+ error: `Cannot edit task with status '${task["status"]}'. Only pending, scheduled, or blocked tasks can be edited.`,
+ },
+ 409,
+ origin,
+ );
+ }
+ const prompt = asString(body["prompt"] ?? task["prompt"]);
+ if (!prompt.trim())
+ return jsonResponse(
+ { error: "prompt cannot be empty", field: "prompt" },
+ 400,
+ origin,
+ );
+ const workingDir = asString(body["working_dir"] ?? task["working_dir"]);
+ const dirError = ensureWorkingDir(
+ workingDir,
+ `working_dir does not exist: ${workingDir}`,
+ );
+ if (dirError) return jsonResponse(dirError, 400, origin);
+ const scheduleType = asString(
+ body["schedule_type"] ?? task["schedule_type"],
+ );
+ const cronExpr = asString(body["cron_expr"] ?? task["cron_expr"]);
+ if (scheduleType === "cron") {
+ if (!cronExpr.trim())
+ return jsonResponse(
+ { error: "cron_expr required for cron schedule", field: "cron_expr" },
+ 400,
+ origin,
+ );
+ if (!cronValid(cronExpr))
+ return jsonResponse(
+ { error: `invalid cron expression: ${cronExpr}`, field: "cron_expr" },
+ 400,
+ origin,
+ );
+ }
+
+ const updates: Row = {};
+ for (const field of [
+ "title",
+ "prompt",
+ "working_dir",
+ "schedule_type",
+ "cron_expr",
+ "delay_seconds",
+ "max_runs",
+ "tags",
+ "agent",
+ "dag_id",
+ ]) {
+ if (field in body) updates[field] = body[field];
+ }
+ if ("prompt_images" in body)
+ updates["prompt_images"] = JSON.stringify(
+ parseJsonList(body["prompt_images"]),
+ );
+ if ("image_paths" in body)
+ updates["image_paths"] = JSON.stringify(
+ parseJsonList(body["image_paths"]),
+ );
+
+ const newScheduleType = asString(
+ updates["schedule_type"] ?? task["schedule_type"],
+ );
+ if (newScheduleType === "immediate") {
+ Object.assign(updates, {
+ status: "pending",
+ next_run_at: null,
+ cron_expr: null,
+ delay_seconds: null,
+ });
+ } else if (newScheduleType === "delayed") {
+ Object.assign(updates, {
+ status: "pending",
+ next_run_at: null,
+ cron_expr: null,
+ });
+ } else if (newScheduleType === "scheduled_at") {
+ const nextRunAt = body["next_run_at"] ?? task["next_run_at"];
+ if (!nextRunAt)
+ return jsonResponse(
+ {
+ error: "next_run_at required for scheduled_at",
+ field: "next_run_at",
+ },
+ 400,
+ origin,
+ );
+ Object.assign(updates, {
+ next_run_at: nextRunAt,
+ status: "scheduled",
+ cron_expr: null,
+ delay_seconds: null,
+ });
+ } else if (newScheduleType === "cron") {
+ const newCron = asString(updates["cron_expr"] ?? task["cron_expr"]);
+ Object.assign(updates, {
+ next_run_at: cronNextIso(newCron),
+ status: "scheduled",
+ delay_seconds: null,
+ });
+ }
+
+ if ("depends_on" in body) {
+ ctx.db.clear_dependencies(tid);
+ const deps = dependencyList(body["depends_on"]);
+ if (deps.length) {
+ ctx.db.add_dependencies_batch(tid, deps);
+ if (
+ deps.some(
+ (dep) => ctx.db.get_task(dep.task_id)?.["status"] !== "completed",
+ )
+ ) {
+ updates["status"] = "blocked";
+ }
+ }
+ }
+ if (Object.keys(updates).length) ctx.db.update_task(tid, updates);
+ const updated = ctx.db.get_task(tid);
+ return jsonResponse(
+ updated ? attachDependencyMetadata(ctx.db, updated) : null,
+ 200,
+ origin,
+ );
+ }
+ return jsonResponse({ error: "not found" }, 404, origin);
+}
+
+async function handleDelete(
+ ctx: ApiContext,
+ url: URL,
+ origin: string,
+): Promise {
+ const path = url.pathname;
+ const parts = path.split("/");
+ if (
+ parts.length === 6 &&
+ parts[2] === "tasks" &&
+ parts[4] === "dependencies"
+ ) {
+ const tid = Number(parts[3]);
+ const depId = Number(parts[5]);
+ ctx.db.remove_dependency(tid, depId);
+ return jsonResponse({ status: "removed" }, 200, origin);
+ }
+ if (path.startsWith("/api/heartbeats/")) {
+ const hid = idAt(path);
+ if (hid !== null) ctx.db.delete_heartbeat(hid);
+ return jsonResponse({ status: "deleted" }, 200, origin);
+ }
+ if (path.startsWith("/api/skills/")) {
+ const sid = idAt(path);
+ if (sid === null)
+ return jsonResponse({ error: "invalid skill id" }, 400, origin);
+ try {
+ ctx.scheduler.remove_skill(sid);
+ return jsonResponse({ status: "deleted" }, 200, origin);
+ } catch (e) {
+ return jsonResponse(
+ { error: e instanceof Error ? e.message : String(e) },
+ 404,
+ origin,
+ );
+ }
+ }
+ if (path.startsWith("/api/tasks/")) {
+ const tid = idAt(path);
+ if (tid !== null) ctx.db.delete_task(tid);
+ return jsonResponse({ status: "deleted" }, 200, origin);
+ }
+ return jsonResponse({ error: "not found" }, 404, origin);
+}
+
+export async function handleApiRequest(
+ ctx: ApiContext,
+ req: Request,
+): Promise {
+ const origin = req.headers.get("Origin") ?? "";
+ if (origin && !isAllowedOrigin(origin)) {
+ return new Response(null, { status: 403 });
+ }
+ if (req.method === "OPTIONS") {
+ return new Response(null, { status: 200, headers: corsHeaders(origin) });
+ }
+ const url = new URL(req.url);
+ if (!url.pathname.startsWith("/api/")) {
+ return jsonResponse({ error: "not found" }, 404, origin);
+ }
+ if (["POST", "PUT", "DELETE"].includes(req.method) && !checkCsrf(req)) {
+ void req.body?.cancel();
+ return jsonResponse(
+ { error: "CSRF token missing or invalid" },
+ 403,
+ origin,
+ );
+ }
+
+ try {
+ if (req.method === "GET") return await handleGet(ctx, req, url, origin);
+ if (req.method === "POST") return await handlePost(ctx, req, url, origin);
+ if (req.method === "PUT") return await handlePut(ctx, req, url, origin);
+ if (req.method === "DELETE") return await handleDelete(ctx, url, origin);
+ return jsonResponse({ error: "method not allowed" }, 405, origin);
+ } catch (e) {
+ return jsonResponse(
+ { error: e instanceof Error ? e.message : String(e) },
+ 500,
+ origin,
+ );
+ }
+}
diff --git a/backend/src/bus.ts b/backend/src/bus.ts
new file mode 100644
index 0000000..b0036d7
--- /dev/null
+++ b/backend/src/bus.ts
@@ -0,0 +1,504 @@
+// AgentForge Message Bus — ported from taskboard_bus.py.
+//
+// MessageBus decouples message sources (channels) from the task scheduler:
+//
+// ┌─────────────────────────────────┐
+// │ MessageBus │
+// │ │
+// FeishuChannel ──► │ inbound_queue (AsyncQueue) │──► TaskScheduler
+// UIChannel ──► │ │
+// SlackChannel ──► │ outbound_queue (AsyncQueue) │◄── TaskScheduler._notify()
+// ... ──► │ │
+// └──────────┬───────────────────────┘
+// │ subscribe_outbound()
+// ▼
+// Channel.send(OutboundMessage)
+//
+// Naming follows the Python original exactly (snake_case methods/fields).
+// Python's queue.Queue(timeout=...) is replaced by AsyncQueue: get() returns a
+// Promise that resolves with the next message (resolving early when one is
+// published) or null after the timeout / immediately when block=false. All
+// production call sites either publish (non-blocking) or poll, so this is a
+// faithful minimal equivalent. put_nowait preserves maxsize semantics by
+// throwing when the queue is full, like Python's queue.Full.
+
+// ──────────────────────────── Message Types ────────────────────────────
+
+/** Inbound message action types (enum values identical to Python). */
+export const InboundMessageType = {
+ CREATE_TASK: "create_task", // create a new task
+ RESUME_TASK: "resume_task", // resume an existing task (uses session_id)
+ RESPOND_TASK: "respond_task", // answer a question a task is waiting on
+ CANCEL_TASK: "cancel_task", // cancel a task
+ STATUS_QUERY: "status_query", // query task status
+} as const;
+export type InboundMessageType =
+ (typeof InboundMessageType)[keyof typeof InboundMessageType];
+
+/** Outbound message event types (enum values identical to Python). */
+export const OutboundMessageType = {
+ TASK_COMPLETED: "task_completed",
+ TASK_FAILED: "task_failed",
+ TASK_STARTED: "task_started",
+ TASK_UPDATED: "task_updated",
+ STATUS_RESPONSE: "status_response",
+} as const;
+export type OutboundMessageType =
+ (typeof OutboundMessageType)[keyof typeof OutboundMessageType];
+
+/**
+ * UTC-naive ISO timestamp, matching Python's `datetime.utcnow().isoformat()`
+ * used by the dataclass defaults (note: NOT the local-naive nowIso() in
+ * util.ts — the bus historically stamped messages in UTC).
+ */
+function utcNowIso(): string {
+ // toISOString() → "YYYY-MM-DDTHH:MM:SS.mmmZ"; pad to 6 fractional digits.
+ return new Date().toISOString().replace("Z", "000");
+}
+
+/**
+ * Inbound message from a Channel to the MessageBus.
+ *
+ * payload contents depend on type:
+ * CREATE_TASK -> {"title", "prompt", "working_dir", ...}
+ * RESUME_TASK -> {"task_id", "message"}
+ * RESPOND_TASK -> {"task_id", "answer"}
+ * CANCEL_TASK -> {"task_id"}
+ * STATUS_QUERY -> {"task_id"}
+ * reply_to: optional reply target (e.g. Feishu chat_id / open_id).
+ * metadata: channel-specific context (e.g. Feishu message_id).
+ */
+export interface InboundMessage {
+ type: InboundMessageType;
+ source: string;
+ payload: Record;
+ reply_to: string | null;
+ metadata: Record;
+ created_at: string;
+}
+
+/** Factory applying the Python dataclass defaults for InboundMessage. */
+export function makeInboundMessage(
+ partial: Pick & Partial,
+): InboundMessage {
+ return {
+ payload: {},
+ reply_to: null,
+ metadata: {},
+ created_at: utcNowIso(),
+ ...partial,
+ };
+}
+
+/**
+ * Outbound message published by the scheduler after a task finishes.
+ *
+ * payload: event payload, e.g. {"status", "result", "error", "title"}.
+ * source_msg: the InboundMessage that triggered this result (optional).
+ */
+export interface OutboundMessage {
+ type: OutboundMessageType;
+ task_id: number;
+ payload: Record;
+ source_msg: InboundMessage | null;
+ metadata: Record;
+ created_at: string;
+}
+
+/** Factory applying the Python dataclass defaults for OutboundMessage. */
+export function makeOutboundMessage(
+ partial: Pick & Partial,
+): OutboundMessage {
+ return {
+ payload: {},
+ source_msg: null,
+ metadata: {},
+ created_at: utcNowIso(),
+ ...partial,
+ };
+}
+
+// ──────────────────────────── TaskDB structural type ────────────────────────────
+
+/**
+ * Minimal structural view of TaskDB used by this module (so bus.ts does not
+ * import db.ts). The bus code only ever calls get_task(). Exported for reuse
+ * by channels/tests.
+ */
+export interface TaskDBLike {
+ get_task(task_id: number): Record | null | undefined;
+}
+
+// ──────────────────────────── Async Queue ────────────────────────────
+
+/**
+ * Async-friendly replacement for Python's queue.Queue.
+ * - put_nowait throws when maxsize (> 0) is exceeded (≙ queue.Full).
+ * - get(block, timeoutSeconds) resolves with the next item, resolving early
+ * when one is published; resolves null on timeout or when block=false and
+ * the queue is empty (the Python wrappers map queue.Empty → None).
+ */
+export class AsyncQueue {
+ private items: T[] = [];
+ private waiters: Array<(value: T | null) => void> = [];
+
+ constructor(private readonly maxsize: number = 0) {}
+
+ put_nowait(item: T): void {
+ const waiter = this.waiters.shift();
+ if (waiter) {
+ // Hand off directly to a blocked getter (qsize stays 0, like Python).
+ waiter(item);
+ return;
+ }
+ if (this.maxsize > 0 && this.items.length >= this.maxsize) {
+ throw new Error("queue full");
+ }
+ this.items.push(item);
+ }
+
+ get(block: boolean = true, timeout: number | null = null): Promise {
+ if (this.items.length > 0) {
+ return Promise.resolve(this.items.shift() as T);
+ }
+ if (!block) {
+ return Promise.resolve(null);
+ }
+ return new Promise((resolve) => {
+ let timer: ReturnType | undefined;
+ const waiter = (value: T | null): void => {
+ if (timer !== undefined) clearTimeout(timer);
+ resolve(value);
+ };
+ this.waiters.push(waiter);
+ if (timeout !== null) {
+ timer = setTimeout(() => {
+ const idx = this.waiters.indexOf(waiter);
+ if (idx !== -1) this.waiters.splice(idx, 1);
+ resolve(null);
+ }, timeout * 1000);
+ }
+ });
+ }
+
+ get size(): number {
+ return this.items.length;
+ }
+}
+
+// ──────────────────────────── Message Bus ────────────────────────────
+
+export type OutboundListener = (msg: OutboundMessage) => void;
+
+/**
+ * AgentForge's message bus.
+ *
+ * Provides two queues:
+ * - inbound_queue: channels send task requests to the scheduler.
+ * - outbound_queue: the scheduler publishes task result notifications.
+ */
+export class MessageBus {
+ inbound_queue: AsyncQueue;
+ outbound_queue: AsyncQueue;
+ private _outbound_listeners: OutboundListener[] = [];
+
+ /** maxsize: queue capacity limit; 0 means unbounded. */
+ constructor(maxsize: number = 0) {
+ this.inbound_queue = new AsyncQueue(maxsize);
+ this.outbound_queue = new AsyncQueue(maxsize);
+ }
+
+ // ── inbound helpers ──────────────────────────────────────────
+
+ /** Channels call this to enqueue an inbound message. */
+ publish_inbound(msg: InboundMessage): void {
+ this.inbound_queue.put_nowait(msg);
+ }
+
+ /** The scheduler calls this to take the next inbound message (optional). */
+ get_inbound(
+ block: boolean = true,
+ timeout: number | null = null,
+ ): Promise {
+ return this.inbound_queue.get(block, timeout);
+ }
+
+ // ── outbound helpers ─────────────────────────────────────────
+
+ /**
+ * The scheduler calls this to publish a task result. The message is put on
+ * outbound_queue and all registered listeners are invoked synchronously.
+ */
+ publish_outbound(msg: OutboundMessage): void {
+ this.outbound_queue.put_nowait(msg);
+ for (const listener of this._outbound_listeners) {
+ try {
+ listener(msg);
+ } catch (e) {
+ console.log(`[MessageBus] outbound listener error: ${e}`);
+ }
+ }
+ }
+
+ /** Register an outbound listener (e.g. FeishuChannel's notify function). */
+ subscribe_outbound(listener: OutboundListener): void {
+ this._outbound_listeners.push(listener);
+ }
+
+ /** Remove an outbound listener (no-op when not registered). */
+ unsubscribe_outbound(listener: OutboundListener): void {
+ const idx = this._outbound_listeners.indexOf(listener);
+ if (idx !== -1) this._outbound_listeners.splice(idx, 1);
+ }
+
+ /** Polling channels call this to take the next outbound message. */
+ get_outbound(
+ block: boolean = true,
+ timeout: number | null = null,
+ ): Promise {
+ return this.outbound_queue.get(block, timeout);
+ }
+}
+
+// ──────────────────────────── Channel (abstract) ────────────────────────────
+
+/**
+ * Abstract message channel. Each concrete Channel represents an external
+ * system (HTTP UI, Feishu, Slack, ...). A channel:
+ * 1. Receives external messages, wraps them as InboundMessage onto the bus.
+ * 2. Pushes task results back to the external system via send().
+ *
+ * Legacy compatibility: notify_task(task_id) reads the DB and calls send(),
+ * matching the original FeishuBridge.notify_task() calling convention.
+ */
+export abstract class Channel {
+ name: string;
+ bus: MessageBus;
+ db: TaskDBLike;
+ _running: boolean = false;
+
+ constructor(name: string, bus: MessageBus, db: TaskDBLike) {
+ this.name = name;
+ this.bus = bus;
+ this.db = db;
+ }
+
+ /** Push an outbound message to this channel's external system. */
+ abstract send(msg: OutboundMessage): void;
+
+ /** Start the channel's background listener. */
+ abstract start(): void;
+
+ /** Stop the channel (optional override). */
+ stop(): void {
+ this._running = false;
+ }
+
+ /**
+ * Legacy direct-callback interface: read the task's status, build an
+ * OutboundMessage, and call send().
+ */
+ notify_task(task_id: number): void {
+ const task = this.db.get_task(task_id);
+ if (!task) return;
+ const status = (task["status"] as string | undefined) ?? "";
+ const type_map: Record = {
+ running: OutboundMessageType.TASK_STARTED,
+ completed: OutboundMessageType.TASK_COMPLETED,
+ failed: OutboundMessageType.TASK_FAILED,
+ };
+ const msg_type = type_map[status] ?? OutboundMessageType.TASK_UPDATED;
+ const outbound = makeOutboundMessage({
+ type: msg_type,
+ task_id,
+ payload: {
+ status,
+ result: task["result"] ?? null,
+ error: task["error"] ?? null,
+ title: task["title"] ?? null,
+ },
+ });
+ this.send(outbound);
+ }
+
+ /** Convenience factory that auto-fills the source field. */
+ _make_inbound(
+ msg_type: InboundMessageType,
+ payload: Record,
+ reply_to: string | null = null,
+ metadata: Record | null = null,
+ ): InboundMessage {
+ return makeInboundMessage({
+ type: msg_type,
+ source: this.name,
+ payload,
+ reply_to,
+ metadata: metadata ?? {},
+ });
+ }
+}
+
+// ──────────────────────────── UIChannel ────────────────────────────
+
+/**
+ * HTTP REST API channel (wraps the existing HTTP handler + TaskScheduler).
+ *
+ * Converts browser/React HTTP requests into InboundMessages on the bus, and
+ * caches outbound events in memory for the /api/tasks/{id}/events polling
+ * endpoint. The HTTP handler still drives scheduler/db directly; this channel
+ * mirrors those operations as InboundMessages for a complete event trail.
+ */
+export class UIChannel extends Channel {
+ /** Outbound event cache: task_id -> OutboundMessage[] (insertion-ordered). */
+ _outbound_cache: Map = new Map();
+
+ /** Max number of task IDs to keep in the outbound cache. */
+ _CACHE_MAX_TASKS: number = 1000;
+
+ constructor(bus: MessageBus, db: TaskDBLike) {
+ super("ui", bus, db);
+ bus.subscribe_outbound(this._on_outbound);
+ }
+
+ /** UI channel receives HTTP requests passively; no polling needed. */
+ start(): void {
+ this._running = true;
+ console.log("[UIChannel] Ready (HTTP REST API)");
+ }
+
+ /** Cache the outbound message for HTTP polling endpoints. */
+ send(msg: OutboundMessage): void {
+ let entries = this._outbound_cache.get(msg.task_id);
+ if (!entries) {
+ entries = [];
+ this._outbound_cache.set(msg.task_id, entries);
+ }
+ entries.push(msg);
+ // Evict oldest task entries when the cache grows too large
+ while (this._outbound_cache.size > this._CACHE_MAX_TASKS) {
+ const oldest = this._outbound_cache.keys().next().value;
+ if (oldest === undefined) break;
+ this._outbound_cache.delete(oldest);
+ }
+ }
+
+ /** Return a copy of all cached outbound events for a task. */
+ get_cached_outbound(task_id: number): OutboundMessage[] {
+ return [...(this._outbound_cache.get(task_id) ?? [])];
+ }
+
+ /**
+ * Called by the HTTP handler after creating a task; publishes an
+ * InboundMessage to the bus. kwargs (≙ Python **kwargs) are merged into
+ * the payload.
+ */
+ notify_task_created(
+ task_id: number,
+ prompt: string,
+ working_dir: string = ".",
+ kwargs: Record = {},
+ ): void {
+ this.bus.publish_inbound(
+ this._make_inbound(InboundMessageType.CREATE_TASK, {
+ task_id,
+ prompt,
+ working_dir,
+ ...kwargs,
+ }),
+ );
+ }
+
+ /** Called by the HTTP handler after resuming a task. */
+ notify_task_resumed(task_id: number, message: string): void {
+ this.bus.publish_inbound(
+ this._make_inbound(InboundMessageType.RESUME_TASK, {
+ task_id,
+ message,
+ }),
+ );
+ }
+
+ /**
+ * Subscription callback: write the outbound message into the local cache.
+ * Arrow-function property so the reference passed to subscribe_outbound is
+ * stable and bound (≙ Python bound method).
+ */
+ _on_outbound = (msg: OutboundMessage): void => {
+ this.send(msg);
+ };
+}
+
+// ──────────────────────────── BusAwareSchedulerMixin ────────────────────────────
+
+/**
+ * Publish an OutboundMessage to the bus based on the task's current status.
+ *
+ * override_type forces a specific message type (used when a cron task has
+ * already been rescheduled: the DB status is back to "scheduled" but this
+ * run's result should be announced as TASK_COMPLETED).
+ *
+ * Porting note: Python's BusAwareSchedulerMixin is a mixin class consumed via
+ * `class TaskScheduler(BusAwareSchedulerMixin)`, and taskboard.py calls
+ * `self._bus_notify(task_id[, override_type])` (it relies only on `self.bus`
+ * and `self.db`). TS has no Python-style mixins, so the logic lives in this
+ * standalone helper, and the small BusAwareSchedulerMixin base class below
+ * preserves the `extends` + `this._bus_notify(...)` ergonomics for the
+ * TaskScheduler port.
+ */
+export function bus_notify(
+ bus: MessageBus | null | undefined,
+ db: TaskDBLike,
+ task_id: number,
+ override_type: OutboundMessageType | null = null,
+): void {
+ if (!bus) return;
+ try {
+ const task = db.get_task(task_id);
+ if (!task) return;
+ const status = (task["status"] as string | undefined) ?? "";
+ let msg_type: OutboundMessageType;
+ if (override_type !== null) {
+ msg_type = override_type;
+ } else {
+ const type_map: Record = {
+ running: OutboundMessageType.TASK_STARTED,
+ completed: OutboundMessageType.TASK_COMPLETED,
+ failed: OutboundMessageType.TASK_FAILED,
+ cancelled: OutboundMessageType.TASK_UPDATED,
+ scheduled: OutboundMessageType.TASK_UPDATED,
+ pending: OutboundMessageType.TASK_UPDATED,
+ };
+ msg_type = type_map[status] ?? OutboundMessageType.TASK_UPDATED;
+ }
+ const outbound = makeOutboundMessage({
+ type: msg_type,
+ task_id,
+ payload: {
+ status,
+ result: task["result"] ?? null,
+ error: task["error"] ?? null,
+ title: task["title"] ?? null,
+ },
+ });
+ bus.publish_outbound(outbound);
+ } catch (e) {
+ console.log(`[BusAwareSchedulerMixin] _bus_notify error: ${e}`);
+ }
+}
+
+/**
+ * Base class equivalent of the Python mixin: subclasses (TaskScheduler)
+ * provide `db` and optionally set `bus`, then call `this._bus_notify(...)`
+ * exactly as the Python code does.
+ */
+export abstract class BusAwareSchedulerMixin {
+ bus: MessageBus | null = null;
+ abstract db: TaskDBLike;
+
+ _bus_notify(
+ task_id: number,
+ override_type: OutboundMessageType | null = null,
+ ): void {
+ bus_notify(this.bus, this.db, task_id, override_type);
+ }
+}
diff --git a/backend/src/channels/agent_utils.ts b/backend/src/channels/agent_utils.ts
new file mode 100644
index 0000000..0db9033
--- /dev/null
+++ b/backend/src/channels/agent_utils.ts
@@ -0,0 +1,64 @@
+/**
+ * Shared utilities for switching the default coding agent across all channels.
+ *
+ * Usage from any channel:
+ * /agent claude — switch to Claude Code
+ * /agent codex — switch to Codex CLI
+ */
+
+// Structural view of TaskDB — only what these helpers touch.
+export interface SettingsDB {
+ get_setting(key: string, defaultValue?: string | null): string | null;
+ set_setting(key: string, value: string): void;
+}
+
+// Supported agent names
+export const SUPPORTED_AGENTS: Record = {
+ claude: "Claude Code (claude CLI)",
+ codex: "Codex CLI (openai/codex)",
+};
+export const DEFAULT_AGENT = "codex";
+
+// Regex: /agent
+const AGENT_CMD_RE = /^\/agent\s+(\S+)\s*$/i;
+
+/** Return the agent name argument if `text` is a /agent command, else null. */
+export function parse_agent_command(text: string): string | null {
+ const m = AGENT_CMD_RE.exec(text.trim());
+ return m ? m[1]!.toLowerCase() : null;
+}
+
+/**
+ * If `text` is a /agent command, persist the choice to DB and return a
+ * confirmation string. Returns null if `text` is not an agent command.
+ *
+ * The setting is stored as global `default_agent` (shared across all channels
+ * and the Forge desktop app).
+ */
+export function handle_agent_command(
+ text: string,
+ channel_key: string,
+ db: SettingsDB,
+): string | null {
+ const agent = parse_agent_command(text);
+ if (agent === null) return null;
+
+ if (!(agent in SUPPORTED_AGENTS)) {
+ const names = Object.keys(SUPPORTED_AGENTS)
+ .map((k) => `\`${k}\``)
+ .join(", ");
+ return `❌ Unknown agent \`${agent}\`. Supported: ${names}`;
+ }
+
+ db.set_setting("default_agent", agent);
+ const label = SUPPORTED_AGENTS[agent];
+ return `🤖 Default agent switched to: **${label}**`;
+}
+
+/**
+ * Return the current default agent from settings.
+ * Falls back to the app default if not set.
+ */
+export function resolve_agent(channel_key: string, db: SettingsDB): string {
+ return db.get_setting("default_agent", DEFAULT_AGENT) ?? DEFAULT_AGENT;
+}
diff --git a/backend/src/channels/dir_utils.ts b/backend/src/channels/dir_utils.ts
new file mode 100644
index 0000000..b5879e7
--- /dev/null
+++ b/backend/src/channels/dir_utils.ts
@@ -0,0 +1,116 @@
+/**
+ * Shared utilities for working-directory management across all channels.
+ *
+ * Two features:
+ * 1. /dir (or cd) command — explicit path switch, persists to DB.
+ * 2. Claude-based extraction — ask claude-haiku to pull a path from a
+ * free-text prompt so the user can say "在 ~/myproject 里帮我…"
+ * without typing a /dir command first.
+ */
+
+import type { SettingsDB } from "./agent_utils.ts";
+
+// ── 1. Explicit /dir / cd command ─────────────────────────────────────────
+
+// Patterns we recognise as "switch directory" commands:
+// /dir ~/foo cd ~/foo /cd ~/foo
+const DIR_CMD_RE = /^(?:\/dir|\/cd|cd)\s+(\S+)\s*$/i;
+
+/** Return the path argument if `text` is a /dir or cd command, else null. */
+export function parse_dir_command(text: string): string | null {
+ const m = DIR_CMD_RE.exec(text.trim());
+ return m ? m[1]! : null;
+}
+
+/**
+ * If `text` is a /dir command, persist the new path to DB and return a
+ * confirmation string. Returns null if `text` is not a dir command.
+ *
+ * `channel_key` is the settings key prefix, e.g. "telegram", "slack", "feishu".
+ */
+export function handle_dir_command(
+ text: string,
+ channel_key: string,
+ db: SettingsDB,
+): string | null {
+ const path = parse_dir_command(text);
+ if (path === null) return null;
+
+ const setting_key = `${channel_key}_default_working_dir`;
+ db.set_setting(setting_key, path);
+ return `📁 Working directory set to: ${path}`;
+}
+
+// ── 2. Claude-based path extraction ───────────────────────────────────────
+
+const EXTRACT_SYSTEM =
+ "You are a path-extraction assistant. " +
+ "Given a user message, decide if it explicitly mentions a filesystem path " +
+ "where some work should be done (e.g. ~/projects/foo, /home/user/bar, " +
+ "./myapp, C:\\\\Users\\\\foo). " +
+ "Reply with a JSON object and nothing else:\n" +
+ ' {"path": ""} — if a path is found\n' +
+ ' {"path": null} — if no path is mentioned\n' +
+ "Do NOT invent a path. Only return one that is clearly stated in the message.";
+
+/**
+ * Call the Anthropic Messages API (claude-haiku) to extract an explicit
+ * working directory from `prompt`.
+ * Returns the path string, or null if none found / on any error.
+ */
+export async function extract_working_dir_with_claude(
+ prompt: string,
+): Promise {
+ const api_key = process.env.ANTHROPIC_API_KEY ?? "";
+ if (!api_key) return null;
+
+ try {
+ const resp = await fetch("https://api.anthropic.com/v1/messages", {
+ method: "POST",
+ headers: {
+ "x-api-key": api_key,
+ "anthropic-version": "2023-06-01",
+ "content-type": "application/json",
+ },
+ body: JSON.stringify({
+ model: "claude-haiku-4-5",
+ max_tokens: 64,
+ system: EXTRACT_SYSTEM,
+ messages: [{ role: "user", content: prompt }],
+ }),
+ signal: AbortSignal.timeout(15000),
+ });
+ if (resp.status !== 200) return null;
+
+ const data: any = await resp.json();
+ const text = (data?.content?.[0]?.text ?? "").trim();
+ const parsed = JSON.parse(text);
+ const path = parsed?.path;
+ if (path && typeof path === "string") return path.trim();
+ } catch {
+ /* network/parse errors → no extraction */
+ }
+ return null;
+}
+
+// Seam for tests (≙ pytest monkeypatching dir_utils.extract_working_dir_with_claude):
+// resolve_working_dir reads the extractor through this mutable hook object.
+export const _hooks = { extract_working_dir_with_claude };
+
+/**
+ * Determine the working directory for a new task:
+ * 1. Try to extract an explicit path from `prompt` via Claude.
+ * 2. Fall back to the channel's default_working_dir in DB.
+ * 3. Fall back to "~".
+ */
+export async function resolve_working_dir(
+ prompt: string,
+ channel_key: string,
+ db: SettingsDB,
+): Promise {
+ const extracted = await _hooks.extract_working_dir_with_claude(prompt);
+ if (extracted) return extracted;
+
+ const setting_key = `${channel_key}_default_working_dir`;
+ return db.get_setting(setting_key) || "~";
+}
diff --git a/backend/src/channels/feishu.ts b/backend/src/channels/feishu.ts
new file mode 100644
index 0000000..09dc741
--- /dev/null
+++ b/backend/src/channels/feishu.ts
@@ -0,0 +1,1878 @@
+/**
+ * Feishu/Lark channel for AgentForge.
+ *
+ * The implementation keeps the Python channel's public method names and JSON
+ * shapes, but uses Bun/TypeScript primitives and the official
+ * @larksuiteoapi/node-sdk at runtime. SDK calls are deliberately structural so
+ * tests can inject small fake clients without depending on SDK internals.
+ */
+
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+
+import {
+ Channel,
+ MessageBus,
+ OutboundMessageType,
+ type OutboundMessage,
+ type TaskDBLike,
+} from "../bus.ts";
+import {
+ makeTask,
+ ScheduleType,
+ type PromptImage,
+ type Task,
+} from "../types.ts";
+import {
+ handle_agent_command,
+ resolve_agent,
+ type SettingsDB,
+} from "./agent_utils.ts";
+import { handle_dir_command, resolve_working_dir } from "./dir_utils.ts";
+
+type Row = Record;
+
+export const HELP_TEXT = `**AgentForge Bot** 👋
+发送任意消息即可创建任务。回复任务完成/失败通知即可继续对话。
+
+**命令列表:**
+• \`/status \` — 查看任务详情
+• \`/cancel \` — 取消任务
+• \`/resume \` — 继续执行任务
+• \`/dir \` — 设置默认工作目录
+ 例如:\`/dir ~/workspace/myproject\`
+• \`/agent \` — 切换 coding agent(\`claude\` / \`codex\`)
+• \`/ccu\` — 查看 Claude Code 当前用量(ccu-blocks)
+• \`/help\` — 显示此帮助
+
+**小技巧:**
+• 消息中直接提到路径,Bot 会自动识别并使用。
+ 例如:_在 ~/myapp 里帮我修复登录 bug_
+• 回复任意结果通知即可继续对话。
+`;
+
+export const FEISHU_CARD_MARKDOWN_CHUNK = 7000;
+export const FEISHU_FALLBACK_MARKDOWN_LIMIT = 8000;
+export const FEISHU_CARD_MAX_ELEMENTS = 200;
+export const FEISHU_PANEL_MAX_LINE_ELEMENTS = 80;
+export const FEISHU_PANEL_PLAIN_TEXT_CHUNK = 1800;
+export const FEISHU_THINKING_PREFIX = "[thinking] ";
+export const FEISHU_UPLOADABLE_IMAGE_SUFFIXES = new Set([
+ ".png",
+ ".jpg",
+ ".jpeg",
+ ".gif",
+ ".webp",
+]);
+export const FEISHU_STREAM_EVENT_TYPES = new Set([
+ "assistant",
+ "tool_call",
+ "tool_result",
+ "command_execution",
+ "file_change",
+ "web_search",
+ "error",
+]);
+
+const FEISHU_MARKDOWN_IMAGE_RE = /!\[[^\]]*]\(([^)\n]+)\)/g;
+
+export let FEISHU_AVAILABLE = true;
+
+export function _set_feishu_available(value: boolean): void {
+ FEISHU_AVAILABLE = value;
+}
+
+export const _hooks = {
+ import_lark: async (): Promise =>
+ await import("@larksuiteoapi/node-sdk"),
+};
+
+export interface FeishuTaskDB extends TaskDBLike, SettingsDB {
+ update_task(task_id: number, updates: Record): void;
+ get_task_runs(task_id: number, limit?: number): Row[];
+ get_run_output_events(run_id: number, limit?: number): Row[];
+ get_task_by_feishu_root_msg(root_msg_id: string): Row | null;
+}
+
+export interface FeishuScheduler {
+ submit_task(task: Task): number;
+ add_output_listener(cb: OutputListener): void;
+ remove_output_listener(cb: OutputListener): void;
+}
+
+export type OutputListener = (
+ task_id: number,
+ run_id: number,
+ event_type: string,
+ content: string,
+) => void;
+
+function isPlainObject(value: unknown): value is Row {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+function stringifyCard(card: Row): string {
+ return JSON.stringify(card);
+}
+
+function responseSuccess(response: any): boolean {
+ if (!response) return false;
+ if (typeof response.success === "function")
+ return Boolean(response.success());
+ if (typeof response.code === "number") return response.code === 0;
+ return Boolean(response.success);
+}
+
+function responseMessageId(response: any): string | null {
+ return (
+ response?.data?.message_id ??
+ response?.data?.messageId ??
+ response?.message_id ??
+ response?.messageId ??
+ null
+ );
+}
+
+function responseImageKey(response: any): string | null {
+ return (
+ response?.data?.image_key ??
+ response?.data?.imageKey ??
+ response?.image_key ??
+ null
+ );
+}
+
+function callMaybeAsync(value: T | Promise): Promise {
+ return Promise.resolve(value);
+}
+
+function errStr(e: unknown): string {
+ return e instanceof Error ? e.message : String(e);
+}
+
+function getSenderOpenId(sender: Row): string {
+ const senderId = sender["sender_id"];
+ if (typeof senderId === "string") return senderId;
+ return senderId?.["open_id"] ?? senderId?.["openId"] ?? "unknown";
+}
+
+function localImageMediaType(imagePath: string): string {
+ const ext = path.extname(imagePath).toLowerCase();
+ if (ext === ".png") return "image/png";
+ if (ext === ".gif") return "image/gif";
+ if (ext === ".webp") return "image/webp";
+ return "image/jpeg";
+}
+
+function expandUser(p: string): string {
+ if (p === "~") return os.homedir();
+ if (p.startsWith("~/")) return path.join(os.homedir(), p.slice(2));
+ return p;
+}
+
+function fileUrlPath(target: string): string {
+ try {
+ return new URL(target).pathname;
+ } catch {
+ const rest = target.slice("file://".length);
+ const slash = rest.indexOf("/");
+ return slash >= 0 ? rest.slice(slash) : "";
+ }
+}
+
+function decodePath(target: string): string {
+ try {
+ return decodeURIComponent(target);
+ } catch {
+ return target;
+ }
+}
+
+function extractEvent(data: any): Row {
+ return data?.event ?? data?.data?.event ?? data ?? {};
+}
+
+function extractMessage(data: any): Row {
+ return extractEvent(data)["message"] ?? {};
+}
+
+function extractSender(data: any): Row {
+ return extractEvent(data)["sender"] ?? {};
+}
+
+function asString(value: unknown, fallback = ""): string {
+ if (value === null || value === undefined) return fallback;
+ return String(value);
+}
+
+export class _FeishuStreamWriter {
+ static readonly MIN_INTERVAL = 0.25;
+
+ task_id: number;
+ msg_id: string;
+ _channel: FeishuChannel;
+ _task_title: string;
+ _run_id: number | null = null;
+ _parts: string[] = [];
+ _last_patch = 0;
+ _timer: ReturnType | null = null;
+ _stopped = false;
+ _patch_in_flight = false;
+ _dirty = false;
+
+ constructor(
+ task_id: number,
+ msg_id: string,
+ channel: FeishuChannel,
+ task_title: string,
+ ) {
+ this.task_id = task_id;
+ this.msg_id = msg_id;
+ this._channel = channel;
+ this._task_title = task_title;
+ }
+
+ on_event(
+ task_id: number,
+ run_id: number,
+ event_type: string,
+ content: string,
+ ): void {
+ if (this._stopped || task_id !== this.task_id) return;
+ if (!FEISHU_STREAM_EVENT_TYPES.has(event_type) || content === "") return;
+ let display_content = this._display_content(event_type, content);
+ if (!display_content) return;
+ if (this._run_id === null) {
+ this._run_id = run_id;
+ } else if (this._run_id !== run_id) {
+ this._run_id = run_id;
+ this._parts = [];
+ }
+ if (
+ event_type !== "assistant" &&
+ this._parts.length &&
+ !this._parts.at(-1)!.endsWith("\n")
+ ) {
+ display_content = "\n" + display_content;
+ }
+ this._parts.push(display_content);
+ this._schedule();
+ }
+
+ _display_content(event_type: string, content: string): string {
+ if (event_type !== "assistant")
+ return this._format_trace_event(event_type, content);
+ return content.startsWith(FEISHU_THINKING_PREFIX)
+ ? content.slice(FEISHU_THINKING_PREFIX.length)
+ : content;
+ }
+
+ _load_trace_payload(content: string): Row {
+ try {
+ const payload = JSON.parse(content);
+ return isPlainObject(payload) ? payload : { content: payload };
+ } catch {
+ return { content };
+ }
+ }
+
+ _format_trace_value(value: unknown): string {
+ return this._compact_trace_summary(value);
+ }
+
+ _compact_trace_summary(value: unknown, limit = 140): string {
+ if (
+ value === null ||
+ value === undefined ||
+ value === "" ||
+ (Array.isArray(value) && value.length === 0) ||
+ (isPlainObject(value) && Object.keys(value).length === 0)
+ ) {
+ return "";
+ }
+ if (isPlainObject(value)) {
+ for (const key of [
+ "command",
+ "query",
+ "path",
+ "file",
+ "message",
+ "content",
+ "text",
+ ]) {
+ if (value[key]) return this._compact_trace_summary(value[key], limit);
+ }
+ const safe_parts: string[] = [];
+ for (const [key, item] of Object.entries(value)) {
+ if (item === null || item === undefined || item === "") continue;
+ if (
+ ["token", "secret", "password", "key"].some((s) =>
+ key.toLowerCase().includes(s),
+ )
+ ) {
+ continue;
+ }
+ safe_parts.push(`${key}=${this._compact_trace_summary(item, 48)}`);
+ if (safe_parts.length >= 2) break;
+ }
+ return this._truncate_trace_text(safe_parts.join(", "), limit);
+ }
+ if (Array.isArray(value)) {
+ const first = this._compact_trace_summary(
+ value[0],
+ Math.max(24, limit - 20),
+ );
+ const suffix = value.length > 1 ? ` 等 ${value.length} 项` : "";
+ return this._truncate_trace_text(`${first}${suffix}`, limit);
+ }
+ return this._truncate_trace_text(String(value), limit);
+ }
+
+ _truncate_trace_text(value: string, limit = 140): string {
+ const lines = String(value)
+ .replace(/\r\n/g, "\n")
+ .replace(/\r/g, "\n")
+ .split("\n")
+ .map((line) => line.trim())
+ .filter(Boolean);
+ const normalized = (lines[0] ?? "").split(/\s+/).join(" ");
+ return normalized.length <= limit
+ ? normalized
+ : normalized.slice(0, limit - 1).trimEnd() + "…";
+ }
+
+ _trace_line(icon: string, label: string, ...parts: unknown[]): string {
+ const compact_parts = parts.map((part) =>
+ typeof part === "string"
+ ? this._truncate_trace_text(part)
+ : this._compact_trace_summary(part),
+ );
+ const suffix = compact_parts.filter(Boolean).join(" · ");
+ return `${icon} ${label}${suffix ? " " + suffix : ""}`;
+ }
+
+ _format_trace_event(event_type: string, content: string): string {
+ const payload = this._load_trace_payload(content);
+ let line = "";
+ if (event_type === "tool_call") {
+ let name = payload["name"] || payload["tool"] || "unknown";
+ if (payload["server"]) name = `${payload["server"]}.${name}`;
+ line = this._trace_line(
+ "▣",
+ "调用工具",
+ name,
+ payload["input"] || payload["arguments"],
+ payload["result"],
+ payload["status"],
+ payload["error"]
+ ? `错误 ${this._format_trace_value(payload["error"])}`
+ : "",
+ );
+ } else if (event_type === "tool_result") {
+ line = this._trace_line(
+ "↵",
+ payload["is_error"] ? "工具错误" : "工具返回",
+ payload["tool_use_id"],
+ payload["content"],
+ );
+ } else if (event_type === "command_execution") {
+ line = this._trace_line(
+ "$",
+ "执行命令",
+ payload["command"] || payload["content"] || "",
+ payload["output"],
+ payload["exit_code"] !== undefined && payload["exit_code"] !== null
+ ? `退出码 ${payload["exit_code"]}`
+ : "",
+ payload["status"],
+ );
+ } else if (event_type === "file_change") {
+ let summary = "";
+ const changes = payload["changes"];
+ if (Array.isArray(changes)) {
+ const summaries = changes
+ .filter(isPlainObject)
+ .map((change) =>
+ `${change["kind"] || change["type"] || "changed"}: ${change["path"] || change["file"] || ""}`.trim(),
+ );
+ summary = summaries.slice(0, 3).join(";");
+ if (summaries.length > 3) summary += ` 等 ${summaries.length} 项`;
+ } else if (changes) {
+ summary = this._format_trace_value(changes);
+ }
+ line = this._trace_line("◇", "文件变更", summary, payload["status"]);
+ } else if (event_type === "web_search") {
+ line = this._trace_line(
+ "⌕",
+ "网页搜索",
+ payload["query"] || payload["content"] || "",
+ payload["status"],
+ );
+ } else if (event_type === "error") {
+ line = this._trace_line(
+ "!",
+ "错误",
+ payload["message"] || payload["content"] || content,
+ );
+ } else {
+ line = this._trace_line("•", `[${event_type}]`, content);
+ }
+ return line ? `${line}\n` : "";
+ }
+
+ _schedule(): void {
+ if (this._stopped) return;
+ this._dirty = true;
+ this._schedule_dirty_locked();
+ }
+
+ _schedule_dirty_locked(): void {
+ if (this._stopped || !this._dirty || this._patch_in_flight || this._timer)
+ return;
+ const delay = Math.max(
+ 0,
+ _FeishuStreamWriter.MIN_INTERVAL - (Date.now() / 1000 - this._last_patch),
+ );
+ if (delay <= 0) {
+ this._start_patch_locked();
+ return;
+ }
+ this._timer = setTimeout(() => this._timer_fired(), delay * 1000);
+ }
+
+ _start_patch_locked(): void {
+ this._patch_in_flight = true;
+ this._dirty = false;
+ void this._do_patch();
+ }
+
+ _timer_fired(): void {
+ this._timer = null;
+ this._schedule_dirty_locked();
+ }
+
+ async _do_patch(): Promise {
+ const text = this._parts.join("");
+ const card = this._channel._build_streaming_card(
+ this.task_id,
+ this._task_title,
+ text,
+ );
+ try {
+ await this._channel._patch_message(this.msg_id, card);
+ } finally {
+ this._last_patch = Date.now() / 1000;
+ this._patch_in_flight = false;
+ this._schedule_dirty_locked();
+ }
+ }
+
+ snapshot_text(): string {
+ return this._parts.join("");
+ }
+
+ stop(): void {
+ this._stopped = true;
+ if (this._timer) {
+ clearTimeout(this._timer);
+ this._timer = null;
+ }
+ }
+}
+
+export class FeishuChannel extends Channel {
+ declare db: FeishuTaskDB;
+ scheduler: FeishuScheduler;
+ _client: any = null;
+ _ws_client: any = null;
+ _ws_promise: Promise | null = null;
+ _event_dispatcher: any = null;
+ _lark: any = null;
+
+ _task_origin: Map = new Map();
+ _notification_map: Map = new Map();
+ _root_msg_map: Map = new Map();
+ _writers: Map = new Map();
+ _writer_listeners: Map = new Map();
+ _streaming_msg: Map = new Map();
+
+ constructor(bus: MessageBus, db: FeishuTaskDB, scheduler: FeishuScheduler) {
+ super("feishu", bus, db);
+ this.scheduler = scheduler;
+ bus.subscribe_outbound(this._on_outbound);
+ }
+
+ start(): void {
+ void this._start().catch((e) => {
+ console.log(`[Feishu] ERROR during initialization: ${e}`);
+ });
+ }
+
+ async _start(): Promise {
+ if (!FEISHU_AVAILABLE) {
+ console.log(
+ "[Feishu] @larksuiteoapi/node-sdk not installed. Run: bun add @larksuiteoapi/node-sdk",
+ );
+ return;
+ }
+ const appId = this.db.get_setting("feishu_app_id") || "";
+ const appSecret = this.db.get_setting("feishu_app_secret") || "";
+ if (!appId || !appSecret) {
+ console.log(
+ "[Feishu] Not configured - set feishu_app_id / feishu_app_secret in settings",
+ );
+ return;
+ }
+ try {
+ const lark = await _hooks.import_lark();
+ this._lark = lark;
+ this._client = new lark.Client({
+ appId,
+ appSecret,
+ appType: lark.AppType?.SelfBuild,
+ domain: lark.Domain?.Feishu,
+ });
+ const wsCtor = lark.WSClient ?? lark.ws?.Client;
+ if (wsCtor) {
+ const eventDispatcher = this._build_event_dispatcher(lark);
+ this._ws_client = new wsCtor({
+ appId,
+ appSecret,
+ });
+ this._event_dispatcher = eventDispatcher;
+ }
+ this._running = true;
+ if (this._ws_client) {
+ this._ws_promise = this._run_ws(this._event_dispatcher);
+ }
+ console.log("[Feishu] Initialization complete");
+ } catch (e) {
+ console.log(`[Feishu] ERROR during initialization: ${e}`);
+ this._client = null;
+ this._ws_client = null;
+ this._event_dispatcher = null;
+ }
+ }
+
+ _build_event_dispatcher(lark: any): any {
+ const handlers: Row = {
+ "im.message.receive_v1": (data: any) => this._on_message_sync(data),
+ "im.chat.member.bot.added_v1": (data: any) => this._on_bot_added(data),
+ "im.message.reaction.created_v1": (data: any) => this._on_reaction(data),
+ "im.message.reaction.deleted_v1": (data: any) => this._on_reaction(data),
+ "im.message.message_read_v1": () => undefined,
+ "im.message.recalled_v1": () => undefined,
+ };
+ if (typeof lark.EventDispatcher === "function") {
+ return new lark.EventDispatcher({}).register(handlers);
+ }
+ return { register: handlers };
+ }
+
+ stop(): void {
+ console.log("[Feishu] Stopping WebSocket bot...");
+ this._running = false;
+ this.bus.unsubscribe_outbound(this._on_outbound);
+ for (const [task_id, writer] of this._writers) {
+ const listener = this._writer_listeners.get(task_id);
+ if (listener) this.scheduler.remove_output_listener(listener);
+ writer.stop();
+ }
+ this._writers.clear();
+ this._writer_listeners.clear();
+ this._streaming_msg.clear();
+ try {
+ if (typeof this._ws_client?.stop === "function") this._ws_client.stop();
+ else if (typeof this._ws_client?.disconnect === "function")
+ void this._ws_client.disconnect();
+ else if (typeof this._ws_client?.close === "function")
+ this._ws_client.close({ force: true });
+ } catch (e) {
+ console.log(`[Feishu] Error stopping ws_client: ${e}`);
+ }
+ this._client = null;
+ this._ws_client = null;
+ this._ws_promise = null;
+ this._event_dispatcher = null;
+ }
+
+ async _run_ws(eventDispatcher: any = null): Promise {
+ try {
+ if (!this._running || !this._ws_client) return;
+ if (typeof this._ws_client.start === "function") {
+ await this._ws_client.start({ eventDispatcher });
+ return;
+ } else if (typeof this._ws_client.connect === "function") {
+ await this._ws_client.connect();
+ }
+ } catch (e) {
+ if (this._running) console.log(`[Feishu] WebSocket error: ${e}`);
+ this._running = false;
+ }
+ }
+
+ send(msg: OutboundMessage): void {
+ void this._send(msg).catch((e) => console.log(`[Feishu] send error: ${e}`));
+ }
+
+ async _send(msg: OutboundMessage): Promise {
+ if (
+ msg.type !== OutboundMessageType.TASK_COMPLETED &&
+ msg.type !== OutboundMessageType.TASK_FAILED
+ )
+ return;
+ const task_id = msg.task_id;
+ if (!this._client) {
+ console.log(
+ `[Feishu] Client not initialized, skipping notification for task ${task_id}`,
+ );
+ return;
+ }
+ const task = this.db.get_task(task_id) as Row | null;
+ if (!task) {
+ console.log(`[Feishu] Task ${task_id} not found in database`);
+ return;
+ }
+
+ const is_completed = msg.type === OutboundMessageType.TASK_COMPLETED;
+ let content: string;
+ if (is_completed) {
+ const result_text = asString(
+ msg.payload["result"] ?? task["result"],
+ ).trim();
+ content = result_text || "Done.";
+ } else {
+ content = asString(
+ msg.payload["error"] ?? task["error"] ?? "Unknown error",
+ )
+ .trim()
+ .slice(0, 800);
+ }
+
+ const origin = this._task_origin.get(task_id);
+ const streaming_msg_id = this._streaming_msg.get(task_id) ?? null;
+ this._streaming_msg.delete(task_id);
+ const streaming_history = this._stop_streaming(task_id);
+
+ let image_keys: string[] = [];
+ if (is_completed) {
+ const image_paths = this._collect_generated_image_paths(
+ task_id,
+ content,
+ task,
+ );
+ const uploaded_images = await this._upload_image_entries(image_paths);
+ image_keys = uploaded_images.map(([, image_key]) => image_key);
+ if (image_keys.length) {
+ content = this._hide_generated_image_paths(
+ content,
+ image_keys.length,
+ uploaded_images.map(([image_path]) => image_path),
+ );
+ }
+ }
+
+ const card = this._build_notification_card({
+ task_id,
+ task,
+ is_completed,
+ body_text: content,
+ streaming_history,
+ image_keys,
+ });
+
+ let sent_id: string | null = null;
+ if (origin) {
+ const [, root_msg_id, reaction_msg_id] = origin;
+ this._add_reaction(reaction_msg_id, is_completed ? "DONE" : "Cry");
+ if (
+ streaming_msg_id &&
+ (await this._patch_message(streaming_msg_id, card))
+ ) {
+ sent_id = streaming_msg_id;
+ }
+ if (!sent_id) {
+ sent_id = await this._reply_message(root_msg_id, content, card);
+ }
+ }
+
+ if (!sent_id) {
+ const chat_id = this.db.get_setting("feishu_default_chat_id");
+ if (chat_id) {
+ sent_id = await this._send_message(
+ chat_id,
+ content,
+ card,
+ this._truncate_text(content, FEISHU_FALLBACK_MARKDOWN_LIMIT),
+ );
+ }
+ }
+
+ if (sent_id) {
+ this._notification_map.set(sent_id, task_id);
+ console.log(
+ `[Feishu] Notification message_id=${sent_id} mapped to task #${task_id}`,
+ );
+ } else {
+ console.log(`[Feishu] Failed to send notification for task ${task_id}`);
+ }
+ this._task_origin.delete(task_id);
+ }
+
+ _on_outbound = (msg: OutboundMessage): void => {
+ this.send(msg);
+ };
+
+ _generated_image_paths_for_task(task_id: number): string[] {
+ let runs: Row[];
+ try {
+ runs = this.db.get_task_runs(task_id, 1);
+ } catch (e) {
+ console.log(`[Feishu] Failed to load runs for generated images: ${e}`);
+ return [];
+ }
+ if (!Array.isArray(runs) || !runs.length) return [];
+ const run_id = runs[0]?.["id"];
+ if (!run_id) return [];
+ let events: Row[];
+ try {
+ events = this.db.get_run_output_events(run_id, 1000);
+ } catch (e) {
+ console.log(
+ `[Feishu] Failed to load output events for generated images: ${e}`,
+ );
+ return [];
+ }
+ const paths: string[] = [];
+ const seen = new Set();
+ for (const event of events) {
+ if (!isPlainObject(event) || event["event_type"] !== "generated_image")
+ continue;
+ try {
+ const payload = JSON.parse(event["content"] || "{}");
+ const imagePath = payload?.path;
+ if (
+ imagePath &&
+ !seen.has(imagePath) &&
+ fs.existsSync(imagePath) &&
+ fs.statSync(imagePath).isFile()
+ ) {
+ seen.add(imagePath);
+ paths.push(imagePath);
+ }
+ } catch {
+ // Ignore malformed generated-image event payloads.
+ }
+ }
+ return paths;
+ }
+
+ _collect_generated_image_paths(
+ task_id: number,
+ content: string,
+ task: Row | null = null,
+ ): string[] {
+ const paths = this._generated_image_paths_for_task(task_id);
+ paths.push(
+ ...this._generated_image_paths_from_markdown(
+ content,
+ task?.["working_dir"],
+ ),
+ );
+ return this._dedupe_image_paths(paths);
+ }
+
+ _generated_image_paths_from_markdown(
+ content: string,
+ working_dir: string | null = null,
+ ): string[] {
+ const paths: string[] = [];
+ FEISHU_MARKDOWN_IMAGE_RE.lastIndex = 0;
+ for (const match of content.matchAll(FEISHU_MARKDOWN_IMAGE_RE)) {
+ const imagePath = this._local_image_path_from_reference(
+ match[1] ?? "",
+ working_dir,
+ );
+ if (imagePath) paths.push(imagePath);
+ }
+ return paths;
+ }
+
+ _local_image_path_from_reference(
+ reference: string,
+ working_dir: string | null = null,
+ ): string | null {
+ let target = this._markdown_image_reference_target(reference);
+ if (
+ !target ||
+ target.startsWith("http://") ||
+ target.startsWith("https://") ||
+ target.startsWith("data:")
+ ) {
+ return null;
+ }
+ if (target.startsWith("file://")) target = fileUrlPath(target);
+ else if (target.startsWith("sandbox:"))
+ target = target.slice("sandbox:".length);
+ target = decodePath(target).trim();
+ if (!target) return null;
+ let imagePath = expandUser(target);
+ if (!path.isAbsolute(imagePath) && working_dir)
+ imagePath = path.join(expandUser(working_dir), imagePath);
+ if (
+ !FEISHU_UPLOADABLE_IMAGE_SUFFIXES.has(
+ path.extname(imagePath).toLowerCase(),
+ )
+ )
+ return null;
+ try {
+ if (!fs.statSync(imagePath).isFile()) return null;
+ return fs.realpathSync(imagePath);
+ } catch {
+ return null;
+ }
+ }
+
+ _markdown_image_reference_target(reference: string): string {
+ const raw = (reference || "").trim();
+ if (!raw) return "";
+ if (raw.startsWith("<")) {
+ const end = raw.indexOf(">");
+ if (end >= 0) return raw.slice(1, end).trim();
+ }
+ if (raw[0] === "'" || raw[0] === '"') {
+ const end = raw.indexOf(raw[0], 1);
+ if (end > 0) return raw.slice(1, end).trim();
+ }
+ const titled = raw.match(/(.+?)\s+['"][^'"]*['"]\s*$/);
+ return (titled ? titled[1]! : raw).trim();
+ }
+
+ _dedupe_image_paths(image_paths: string[]): string[] {
+ const deduped: string[] = [];
+ const seen = new Set();
+ for (const imagePath of image_paths) {
+ const canonical = this._canonical_image_path(imagePath);
+ if (!canonical || seen.has(canonical)) continue;
+ seen.add(canonical);
+ deduped.push(canonical);
+ }
+ return deduped;
+ }
+
+ _canonical_image_path(imagePath: string | null): string | null {
+ if (!imagePath) return null;
+ try {
+ const expanded = expandUser(imagePath);
+ if (!fs.statSync(expanded).isFile()) return null;
+ return fs.realpathSync(expanded);
+ } catch {
+ return null;
+ }
+ }
+
+ async _upload_images(image_paths: string[]): Promise {
+ return (await this._upload_image_entries(image_paths)).map(
+ ([, image_key]) => image_key,
+ );
+ }
+
+ async _upload_image_entries(
+ image_paths: string[],
+ ): Promise> {
+ const entries: Array<[string, string]> = [];
+ for (const imagePath of image_paths) {
+ const imageKey = await this._upload_image(imagePath);
+ if (imageKey) entries.push([imagePath, imageKey]);
+ }
+ return entries;
+ }
+
+ async _upload_image(imagePath: string): Promise {
+ if (!this._client) return null;
+ try {
+ const image = fs.readFileSync(imagePath);
+ let response: any;
+ if (this._client.im?.v1?.image?.create) {
+ response = await callMaybeAsync(
+ this._client.im.v1.image.create({
+ request_body: { image_type: "message", image },
+ data: { image_type: "message", image },
+ }),
+ );
+ } else {
+ response = await callMaybeAsync(
+ this._client.im.image.create({
+ data: { image_type: "message", image },
+ }),
+ );
+ }
+ if (responseSuccess(response)) return responseImageKey(response);
+ console.log(
+ `[Feishu] Image upload failed: ${response?.code} ${response?.msg}`,
+ );
+ return null;
+ } catch (e) {
+ console.log(
+ `[Feishu] Failed to upload generated image ${imagePath}: ${e}`,
+ );
+ return null;
+ }
+ }
+
+ _hide_generated_image_paths(
+ content: string,
+ image_count: number,
+ uploaded_paths: string[] = [],
+ ): string {
+ const uploaded = new Set(
+ uploaded_paths
+ .map((p) => this._canonical_image_path(p))
+ .filter((p): p is string => Boolean(p)),
+ );
+ const lines: string[] = [];
+ for (const line of (content || "").split(/\r?\n/)) {
+ const stripped = line.trim();
+ if (!stripped) {
+ lines.push("");
+ continue;
+ }
+ if (this._line_is_uploaded_image_path(stripped, uploaded)) continue;
+ const cleaned = this._remove_uploaded_markdown_image_refs(line, uploaded);
+ const visible = cleaned.trim();
+ if (visible && !["-", "*", "+"].includes(visible))
+ lines.push(cleaned.trimEnd());
+ }
+ const cleaned = lines.join("\n").trim();
+ if (!cleaned || cleaned.startsWith("已生成"))
+ return `已生成 ${image_count} 张图片。`;
+ return cleaned;
+ }
+
+ _line_is_uploaded_image_path(
+ stripped_line: string,
+ uploaded_paths: Set,
+ ): boolean {
+ if (!stripped_line.startsWith("- ")) return false;
+ const canonical = this._canonical_image_path(stripped_line.slice(2).trim());
+ return Boolean(
+ (canonical && uploaded_paths.has(canonical)) ||
+ stripped_line.includes("/.codex/generated_images/"),
+ );
+ }
+
+ _remove_uploaded_markdown_image_refs(
+ line: string,
+ uploaded_paths: Set,
+ ): string {
+ if (!uploaded_paths.size) return line;
+ return line.replace(FEISHU_MARKDOWN_IMAGE_RE, (full, target) => {
+ const imagePath = this._local_image_path_from_reference(target);
+ const canonical = this._canonical_image_path(imagePath);
+ return canonical && uploaded_paths.has(canonical) ? "" : full;
+ });
+ }
+
+ async _send_message(
+ chat_id: string,
+ content: string,
+ card: Row | null = null,
+ fallback_content: string | null = null,
+ ): Promise {
+ if (!this._client) return null;
+ try {
+ const receive_id_type = chat_id.startsWith("oc_") ? "chat_id" : "open_id";
+ const card_payload = card ?? this._build_legacy_markdown_card(content);
+ const message_id = await this._create_message(
+ receive_id_type,
+ chat_id,
+ card_payload,
+ );
+ if (message_id) return message_id;
+ if (card !== null) {
+ return await this._create_message(
+ receive_id_type,
+ chat_id,
+ this._build_legacy_markdown_card(fallback_content ?? content),
+ );
+ }
+ return null;
+ } catch (e) {
+ console.log(`[Feishu] Error sending message: ${e}`);
+ return null;
+ }
+ }
+
+ async _reply_message(
+ parent_message_id: string,
+ content: string,
+ card: Row | null = null,
+ ): Promise {
+ if (!this._client) return null;
+ try {
+ const reply_card = card ?? this._build_legacy_markdown_card(content);
+ const message_id = await this._create_reply(
+ parent_message_id,
+ reply_card,
+ );
+ if (message_id) return message_id;
+ if (card !== null)
+ return await this._create_reply(
+ parent_message_id,
+ this._build_legacy_markdown_card(content),
+ );
+ return null;
+ } catch (e) {
+ console.log(`[Feishu] Error replying to message: ${e}`);
+ return null;
+ }
+ }
+
+ async _create_message(
+ receive_id_type: string,
+ chat_id: string,
+ card: Row,
+ ): Promise {
+ if (!this._client) return null;
+ const request = {
+ receive_id_type,
+ params: { receive_id_type },
+ request_body: {
+ receive_id: chat_id,
+ msg_type: "interactive",
+ content: stringifyCard(card),
+ },
+ data: {
+ receive_id: chat_id,
+ msg_type: "interactive",
+ content: stringifyCard(card),
+ },
+ };
+ const response = this._client.im?.v1?.message?.create
+ ? await callMaybeAsync(this._client.im.v1.message.create(request))
+ : await callMaybeAsync(this._client.im.message.create(request));
+ if (responseSuccess(response)) return responseMessageId(response);
+ console.log(`[Feishu] Send failed: ${response?.code} ${response?.msg}`);
+ return null;
+ }
+
+ async _create_reply(
+ parent_message_id: string,
+ card: Row,
+ ): Promise {
+ if (!this._client) return null;
+ const request = {
+ message_id: parent_message_id,
+ path: { message_id: parent_message_id },
+ request_body: {
+ msg_type: "interactive",
+ content: stringifyCard(card),
+ reply_in_thread: true,
+ },
+ data: {
+ msg_type: "interactive",
+ content: stringifyCard(card),
+ reply_in_thread: true,
+ },
+ };
+ const response = this._client.im?.v1?.message?.reply
+ ? await callMaybeAsync(this._client.im.v1.message.reply(request))
+ : await callMaybeAsync(this._client.im.message.reply(request));
+ if (responseSuccess(response)) return responseMessageId(response);
+ console.log(`[Feishu] Reply failed: ${response?.code} ${response?.msg}`);
+ return null;
+ }
+
+ async _patch_message(message_id: string, card: Row): Promise {
+ if (!this._client) return false;
+ try {
+ const request = {
+ message_id,
+ path: { message_id },
+ request_body: { content: stringifyCard(card) },
+ data: { content: stringifyCard(card) },
+ };
+ const response = this._client.im?.v1?.message?.patch
+ ? await callMaybeAsync(this._client.im.v1.message.patch(request))
+ : await callMaybeAsync(this._client.im.message.patch(request));
+ if (responseSuccess(response)) return true;
+ console.log(`[Feishu] Patch failed: ${response?.code} ${response?.msg}`);
+ return false;
+ } catch (e) {
+ console.log(`[Feishu] Error patching message ${message_id}: ${e}`);
+ return false;
+ }
+ }
+
+ _build_streaming_card(
+ task_id: number,
+ task_title: string,
+ output_text: string,
+ done = false,
+ ): Row {
+ let elements: Row[];
+ if (done) {
+ const display_text = output_text.trim() || "完成";
+ elements = [
+ {
+ tag: "markdown",
+ content: this._preserve_feishu_markdown_linebreaks(display_text),
+ },
+ ];
+ } else if (!output_text.trim()) {
+ elements = [{ tag: "markdown", content: "Thinking ▌" }];
+ } else {
+ elements = [this._build_streaming_history_panel(output_text, true)];
+ }
+ return {
+ schema: "2.0",
+ config: { wide_screen_mode: true, width_mode: "fill" },
+ body: { elements },
+ };
+ }
+
+ _build_streaming_history_panel(output_text: string, expanded = false): Row {
+ return {
+ tag: "collapsible_panel",
+ expanded,
+ header: {
+ title: { tag: "plain_text", content: "执行过程" },
+ vertical_align: "center",
+ icon: {
+ tag: "standard_icon",
+ token: "down-small-ccm_outlined",
+ color: "",
+ size: "16px 16px",
+ },
+ icon_position: "right",
+ icon_expanded_angle: -180,
+ },
+ border: { color: "grey", corner_radius: "5px" },
+ vertical_spacing: "8px",
+ padding: "8px 8px 8px 8px",
+ elements: this._build_streaming_history_elements(output_text),
+ };
+ }
+
+ _build_streaming_history_elements(output_text: string): Row[] {
+ const normalized = output_text
+ .replace(/\r\n/g, "\n")
+ .replace(/\r/g, "\n")
+ .replace(/\n+$/g, "");
+ if (!normalized) return [];
+ const line_elements =
+ this._build_streaming_history_line_elements(normalized);
+ return line_elements.length <= FEISHU_PANEL_MAX_LINE_ELEMENTS
+ ? line_elements
+ : this._build_streaming_history_markdown_elements(normalized);
+ }
+
+ _build_streaming_history_markdown_elements(text: string): Row[] {
+ return this._chunk_text(text, FEISHU_CARD_MARKDOWN_CHUNK - 16).map(
+ (chunk) => ({
+ tag: "markdown",
+ content: chunk,
+ }),
+ );
+ }
+
+ _build_streaming_history_line_elements(text: string): Row[] {
+ const elements: Row[] = [];
+ for (const line of text.split("\n")) {
+ for (const chunk of line
+ ? this._chunk_text(line, FEISHU_PANEL_PLAIN_TEXT_CHUNK)
+ : [" "]) {
+ elements.push(this._build_streaming_history_line(chunk));
+ }
+ }
+ return elements;
+ }
+
+ _build_streaming_history_line(content: string): Row {
+ return {
+ tag: "div",
+ text: {
+ tag: "plain_text",
+ text_color: "grey",
+ text_size: "notation",
+ content,
+ },
+ };
+ }
+
+ _preserve_feishu_markdown_linebreaks(text: string): string {
+ return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
+ }
+
+ _start_streaming(
+ task_id: number,
+ running_msg_id: string,
+ task_title: string,
+ ): void {
+ this._stop_streaming(task_id);
+ const writer = new _FeishuStreamWriter(
+ task_id,
+ running_msg_id,
+ this,
+ task_title,
+ );
+ this._writers.set(task_id, writer);
+ this._streaming_msg.set(task_id, running_msg_id);
+ const listener = writer.on_event.bind(writer);
+ this._writer_listeners.set(task_id, listener);
+ this.scheduler.add_output_listener(listener);
+ }
+
+ _stop_streaming(task_id: number): string | null {
+ const writer = this._writers.get(task_id);
+ if (!writer) return null;
+ this._writers.delete(task_id);
+ const listener = this._writer_listeners.get(task_id);
+ if (listener) {
+ this.scheduler.remove_output_listener(listener);
+ this._writer_listeners.delete(task_id);
+ }
+ const history = writer.snapshot_text();
+ writer.stop();
+ return history;
+ }
+
+ _build_notification_card(args: {
+ task_id: number;
+ task: Row;
+ is_completed: boolean;
+ body_text: string;
+ streaming_history?: string | null;
+ image_keys?: string[] | null;
+ }): Row {
+ const clean_body =
+ (args.body_text || "").trim() ||
+ (args.is_completed ? "Done." : "Unknown error");
+ const summary = clean_body
+ ? this._truncate_text(clean_body.split(/\r?\n/)[0] ?? "", 120)
+ : "";
+ let elements = this._build_result_elements(
+ clean_body,
+ args.image_keys ?? [],
+ );
+ if (args.streaming_history?.trim()) {
+ let panel_text = args.streaming_history;
+ if (args.is_completed) {
+ const stripped = this._strip_final_result_from_history(
+ args.streaming_history,
+ clean_body,
+ );
+ if (stripped.trim()) panel_text = stripped;
+ }
+ elements = [this._build_streaming_history_panel(panel_text)].concat(
+ elements,
+ );
+ }
+ if (!args.is_completed) {
+ elements.push({
+ tag: "markdown",
+ content: `\`/status ${args.task_id}\` for full details`,
+ });
+ }
+ return {
+ schema: "2.0",
+ config: {
+ wide_screen_mode: true,
+ enable_forward: true,
+ width_mode: "fill",
+ summary: { content: summary },
+ },
+ body: { elements },
+ };
+ }
+
+ _strip_final_result_from_history(
+ history: string,
+ final_text: string,
+ ): string {
+ const final_body = (final_text || "").trim();
+ if (!final_body) return history;
+ const trimmed = history.trimEnd();
+ return trimmed.endsWith(final_body)
+ ? trimmed.slice(0, -final_body.length).trimEnd()
+ : history;
+ }
+
+ _build_result_elements(
+ body_text: string,
+ image_keys: string[] | null = null,
+ ): Row[] {
+ const clean_body = (body_text || "").trim() || "Done.";
+ const elements: Row[] = this._chunk_text(
+ clean_body,
+ FEISHU_CARD_MARKDOWN_CHUNK,
+ ).map((chunk) => ({
+ tag: "markdown",
+ content: chunk,
+ }));
+ for (const [index, image_key] of (image_keys ?? []).entries()) {
+ elements.push({
+ tag: "img",
+ img_key: image_key,
+ alt: { tag: "plain_text", content: `generated image ${index + 1}` },
+ });
+ }
+ return elements;
+ }
+
+ _build_legacy_markdown_card(content: string): Row {
+ return {
+ config: { wide_screen_mode: true },
+ elements: [{ tag: "markdown", content }],
+ };
+ }
+
+ _truncate_text(text: string, limit: number): string {
+ const normalized = text.replace(/\r\n/g, "\n").trim();
+ return normalized.length <= limit
+ ? normalized
+ : normalized.slice(0, limit).trimEnd() + "\n…(truncated)";
+ }
+
+ _chunk_text(text: string, limit: number): string[] {
+ const normalized = text.replace(/\r\n/g, "\n");
+ if (!normalized) return [""];
+ const chunks: string[] = [];
+ for (let i = 0; i < normalized.length; i += limit)
+ chunks.push(normalized.slice(i, i + limit));
+ return chunks;
+ }
+
+ _escape_feishu_markdown(text: string): string {
+ return text.replace(/\\/g, "\\\\");
+ }
+
+ _add_reaction(message_id: string, emoji_type = "THUMBSUP"): void {
+ if (!this._client) return;
+ void (async () => {
+ try {
+ const request = {
+ message_id,
+ path: { message_id },
+ request_body: { reaction_type: { emoji_type } },
+ data: { reaction_type: { emoji_type } },
+ };
+ if (this._client.im?.v1?.message_reaction?.create) {
+ await this._client.im.v1.message_reaction.create(request);
+ } else if (this._client.im?.messageReaction?.create) {
+ await this._client.im.messageReaction.create(request);
+ }
+ } catch (e) {
+ console.log(`[Feishu] Failed to add reaction to ${message_id}: ${e}`);
+ }
+ })();
+ }
+
+ _get_usage_stats(): string {
+ return "📊 Claude Code 用量统计在 TypeScript 运行时暂不可用";
+ }
+
+ _extract_forwarded_content(message: Row): Row | null {
+ const msg_type = message["message_type"];
+ if (msg_type === "forward") {
+ try {
+ const content = JSON.parse(message["content"]);
+ return {
+ type: "forward",
+ sender_name: content["sender_name"] ?? "Unknown",
+ sender_id: content["sender_id"] ?? null,
+ timestamp: content["create_time"] ?? null,
+ text: content["text"] ?? "",
+ images: content["images"] ?? [],
+ };
+ } catch {
+ return null;
+ }
+ }
+ if (msg_type === "post") {
+ try {
+ const post_body = JSON.parse(message["content"]);
+ const lang_body =
+ post_body["content"] ??
+ post_body["zh_cn"] ??
+ post_body["en_us"] ??
+ Object.values(post_body)[0] ??
+ {};
+ const paragraphs = Array.isArray(lang_body)
+ ? lang_body
+ : (lang_body["content"] ?? []);
+ for (const para of paragraphs) {
+ for (const elem of para) {
+ if (elem?.tag === "quote") {
+ const user = elem["user"] ?? {};
+ return {
+ type: "quote",
+ sender_name: user["name"] ?? "未知用户",
+ sender_id: user["open_id"] ?? null,
+ text: elem["text"] ?? "",
+ timestamp: elem["create_time"] ?? null,
+ };
+ }
+ if (elem?.tag === "nested_message") {
+ const nested = elem["nested_message"] ?? {};
+ return {
+ type: "forward",
+ sender_name: nested["sender_name"] ?? "未知用户",
+ sender_id: nested["sender_id"] ?? null,
+ timestamp: nested["create_time"] ?? null,
+ text: nested["text"] ?? "",
+ images: nested["images"] ?? [],
+ };
+ }
+ }
+ }
+ } catch {
+ return null;
+ }
+ }
+ return null;
+ }
+
+ _format_forwarded_prompt(
+ original_content: string,
+ forwarded: Row | null,
+ ): string {
+ if (!forwarded) return original_content;
+ const parts = [
+ "📨 [转发消息]",
+ `转发自: ${forwarded["sender_name"] ?? "未知用户"}`,
+ ];
+ if (forwarded["timestamp"]) {
+ const dt = new Date(Number(forwarded["timestamp"]) * 1000);
+ const fmt = new Intl.DateTimeFormat("zh-CN", {
+ timeZone: "Asia/Shanghai",
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ hour: "2-digit",
+ minute: "2-digit",
+ hour12: false,
+ }).format(dt);
+ parts.push(`时间: ${fmt.replaceAll("/", "-")}`);
+ }
+ parts.push("\n--- 转发内容 ---");
+ parts.push(forwarded["text"] ?? "");
+ const images = Array.isArray(forwarded["images"])
+ ? forwarded["images"]
+ : [];
+ if (images.length) parts.push(`\n[包含 ${images.length} 张图片]`);
+ if (original_content.trim()) {
+ parts.push("\n--- 用户附加消息 ---");
+ parts.push(original_content);
+ }
+ return parts.join("\n");
+ }
+
+ async _download_image(
+ message_id: string,
+ image_key: string,
+ ): Promise {
+ if (!this._client) return null;
+ try {
+ const request = {
+ message_id,
+ file_key: image_key,
+ type: "image",
+ path: { message_id, file_key: image_key },
+ params: { type: "image" },
+ };
+ const response = this._client.im?.v1?.message_resource?.get
+ ? await callMaybeAsync(this._client.im.v1.message_resource.get(request))
+ : await callMaybeAsync(this._client.im.messageResource.get(request));
+ if (!responseSuccess(response)) return null;
+ const image_data: Buffer = Buffer.from(
+ response?.raw?.content ?? response?.data ?? [],
+ );
+ let extension = "jpg";
+ if (image_data.subarray(0, 3).equals(Buffer.from([0xff, 0xd8, 0xff])))
+ extension = "jpg";
+ else if (
+ image_data
+ .subarray(0, 8)
+ .equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
+ )
+ extension = "png";
+ else if (
+ image_data.subarray(0, 6).toString() === "GIF87a" ||
+ image_data.subarray(0, 6).toString() === "GIF89a"
+ )
+ extension = "gif";
+ else if (
+ image_data.subarray(0, 4).toString() === "RIFF" &&
+ image_data.subarray(8, 12).toString() === "WEBP"
+ )
+ extension = "webp";
+ const downloads_dir = path.join(
+ os.homedir(),
+ ".agentforge",
+ "feishu_images",
+ );
+ fs.mkdirSync(downloads_dir, { recursive: true });
+ const filePath = path.join(downloads_dir, `${image_key}.${extension}`);
+ fs.writeFileSync(filePath, image_data);
+ return filePath;
+ } catch (e) {
+ console.log(`[Feishu] Error downloading image ${image_key}: ${e}`);
+ return null;
+ }
+ }
+
+ _on_reaction(_data: unknown): void {
+ return;
+ }
+
+ _on_bot_added(data: any): void {
+ const event = extractEvent(data);
+ const chat_id = event["chat_id"];
+ if (chat_id) void this._send_message(chat_id, HELP_TEXT);
+ }
+
+ _on_message_sync(data: any): void {
+ if (!this._running) return;
+ void this._handle_inbound(data).catch((e) =>
+ console.log(`[Feishu] Inbound handler error: ${e}`),
+ );
+ }
+
+ async _handle_inbound(data: any): Promise {
+ const event = extractEvent(data);
+ const message = event["message"] ?? {};
+ const sender = event["sender"] ?? {};
+ if (sender["sender_type"] === "bot") return;
+
+ const parsed = await this._parse_message_content(message);
+ if (!parsed) return;
+ let { content, image_paths } = parsed;
+ const forwarded = this._extract_forwarded_content(message);
+ if (forwarded) {
+ content = this._format_forwarded_prompt(content, forwarded);
+ for (const img of forwarded["images"] ?? []) {
+ const img_key = img?.["image_key"];
+ if (img_key) {
+ const img_path = await this._download_image(
+ message["message_id"],
+ img_key,
+ );
+ if (img_path) image_paths.push(img_path);
+ }
+ }
+ }
+ if (!content) return;
+
+ this._add_reaction(message["message_id"], "OK");
+ const sender_id = getSenderOpenId(sender);
+ const chat_type = message["chat_type"];
+ const chat_id = message["chat_id"];
+ const reply_to = chat_type === "group" ? chat_id : sender_id;
+
+ if (content.trim() === "/help" || content.trim() === "/start") {
+ await this._send_message(reply_to, HELP_TEXT);
+ return;
+ }
+ if (content.startsWith("/dir ") || content.startsWith("/cd ")) {
+ const reply = handle_dir_command(content, "feishu", this.db);
+ if (reply) await this._send_message(reply_to, reply);
+ return;
+ }
+ if (content.startsWith("/agent ")) {
+ const reply = handle_agent_command(content, "feishu", this.db);
+ if (reply) await this._send_message(reply_to, reply);
+ return;
+ }
+ if (content.trim().toLowerCase().startsWith("/ccu")) {
+ await this._send_message(reply_to, this._get_usage_stats());
+ return;
+ }
+ if (content.startsWith("/resume ")) {
+ await this._handle_resume_command(content, reply_to, message);
+ return;
+ }
+ if (content.startsWith("/status ")) {
+ await this._handle_status_command(content, reply_to);
+ return;
+ }
+ if (
+ [
+ "notification",
+ "任务完成",
+ "任务失败",
+ "任务状态",
+ "任务已",
+ "task completed",
+ "task failed",
+ "task status",
+ ].some((keyword) => content.toLowerCase().includes(keyword))
+ ) {
+ return;
+ }
+
+ const parent_id = message["parent_id"] || null;
+ const root_id = message["root_id"] || null;
+ if (parent_id || root_id) {
+ const resumed = await this._try_resume_thread_message(
+ content,
+ reply_to,
+ message,
+ parent_id,
+ root_id,
+ );
+ if (resumed) return;
+ }
+ await this._create_task_from_message(
+ content,
+ image_paths,
+ reply_to,
+ message,
+ );
+ }
+
+ async _parse_message_content(
+ message: Row,
+ ): Promise<{ content: string; image_paths: string[] } | null> {
+ const msg_type = message["message_type"];
+ const image_paths: string[] = [];
+ if (msg_type === "text") {
+ try {
+ return {
+ content: String(JSON.parse(message["content"])["text"] ?? "").trim(),
+ image_paths,
+ };
+ } catch {
+ return {
+ content: String(message["content"] ?? "").trim(),
+ image_paths,
+ };
+ }
+ }
+ if (msg_type === "post") {
+ try {
+ const post_body = JSON.parse(message["content"]);
+ const lang_body = post_body["content"]
+ ? post_body
+ : post_body["zh_cn"] ||
+ post_body["en_us"] ||
+ Object.values(post_body)[0] ||
+ {};
+ const text_parts: string[] = [];
+ for (const para of lang_body["content"] ?? []) {
+ for (const elem of para) {
+ if (elem?.tag === "text") text_parts.push(elem["text"] ?? "");
+ if (elem?.tag === "img" && elem["image_key"]) {
+ const imagePath = await this._download_image(
+ message["message_id"],
+ elem["image_key"],
+ );
+ if (imagePath) image_paths.push(imagePath);
+ }
+ }
+ }
+ const content = [
+ String(lang_body["title"] ?? "").trim(),
+ text_parts.join("").trim(),
+ ]
+ .filter(Boolean)
+ .join("\n");
+ return {
+ content:
+ content || (image_paths.length ? "请分析这些图片的内容" : ""),
+ image_paths,
+ };
+ } catch {
+ return { content: "", image_paths };
+ }
+ }
+ if (msg_type === "image") {
+ try {
+ const image_key = JSON.parse(message["content"])["image_key"];
+ if (image_key) {
+ const imagePath = await this._download_image(
+ message["message_id"],
+ image_key,
+ );
+ if (imagePath) image_paths.push(imagePath);
+ }
+ } catch {
+ // Keep default prompt for malformed image payloads.
+ }
+ return { content: "请分析这张图片的内容", image_paths };
+ }
+ return null;
+ }
+
+ async _handle_resume_command(
+ content: string,
+ reply_to: string,
+ message: Row,
+ ): Promise {
+ const parts = content.slice(8).trim().split(" ");
+ const tid = Number(parts.shift());
+ const resume_msg = parts.join(" ").trim();
+ if (!Number.isInteger(tid) || !resume_msg) {
+ await this._send_message(
+ reply_to,
+ "Usage: `/resume `",
+ );
+ return;
+ }
+ const task = this.db.get_task(tid) as Row | null;
+ if (task?.["session_id"]) {
+ this.db.update_task(tid, {
+ status: "pending",
+ prompt: resume_msg,
+ result: null,
+ error: null,
+ question: null,
+ });
+ this._task_origin.set(tid, [
+ reply_to,
+ message["message_id"],
+ message["message_id"],
+ ]);
+ const title =
+ (this.db.get_task(tid) as Row | null)?.["title"] ?? `Task #${tid}`;
+ const running_msg_id = await this._create_reply(
+ message["message_id"],
+ this._build_streaming_card(tid, title, ""),
+ );
+ if (running_msg_id) this._start_streaming(tid, running_msg_id, title);
+ } else {
+ await this._send_message(
+ reply_to,
+ `❌ Task #${tid} not found or has no saved session.`,
+ );
+ }
+ }
+
+ async _handle_status_command(
+ content: string,
+ reply_to: string,
+ ): Promise {
+ const tid = Number(content.slice(8).trim().split(/\s+/)[0]);
+ if (!Number.isInteger(tid)) return;
+ const task = this.db.get_task(tid) as Row | null;
+ if (!task) {
+ await this._send_message(reply_to, `❌ Task #${tid} not found.`);
+ return;
+ }
+ const icons: Record = {
+ completed: "✅",
+ failed: "❌",
+ running: "⏳",
+ pending: "🕐",
+ cancelled: "🚫",
+ };
+ const icon = icons[String(task["status"])] ?? "❓";
+ await this._send_message(
+ reply_to,
+ `${icon} **Task #${tid}** — ${task["status"]}\n\n**${task["title"]}**`,
+ );
+ }
+
+ async _try_resume_thread_message(
+ content: string,
+ reply_to: string,
+ message: Row,
+ parent_id: string | null,
+ root_id: string | null,
+ ): Promise {
+ let task_id: number | undefined;
+ if (parent_id) task_id = this._notification_map.get(parent_id);
+ if (!task_id && root_id) task_id = this._notification_map.get(root_id);
+ if (!task_id && root_id) task_id = this._root_msg_map.get(root_id);
+ if (!task_id && parent_id) task_id = this._root_msg_map.get(parent_id);
+ if (!task_id) {
+ for (const msg_id of [root_id, parent_id].filter(Boolean) as string[]) {
+ const db_task = this.db.get_task_by_feishu_root_msg(msg_id);
+ if (db_task) {
+ task_id = db_task["id"];
+ break;
+ }
+ }
+ }
+ if (!task_id) return false;
+ const task = this.db.get_task(task_id) as Row | null;
+ const thread_root = root_id || parent_id!;
+ if (task?.["session_id"]) {
+ this.db.update_task(task_id, {
+ status: "pending",
+ prompt: content,
+ result: null,
+ error: null,
+ question: null,
+ });
+ this._task_origin.set(task_id, [
+ reply_to,
+ thread_root,
+ message["message_id"],
+ ]);
+ const title =
+ (this.db.get_task(task_id) as Row | null)?.["title"] ??
+ `Task #${task_id}`;
+ const running_msg_id = await this._create_reply(
+ thread_root,
+ this._build_streaming_card(task_id, title, ""),
+ );
+ if (running_msg_id) this._start_streaming(task_id, running_msg_id, title);
+ } else {
+ await this._reply_message(
+ thread_root,
+ `❌ Task #${task_id} not found or has no saved session.`,
+ );
+ }
+ return true;
+ }
+
+ async _create_task_from_message(
+ content: string,
+ image_paths: string[],
+ reply_to: string,
+ message: Row,
+ ): Promise {
+ const working_dir = await resolve_working_dir(content, "feishu", this.db);
+ const title = content.slice(0, 60) + (content.length > 60 ? "…" : "");
+ const prompt_images: PromptImage[] = [];
+ for (const imagePath of image_paths) {
+ try {
+ prompt_images.push({
+ name: path.basename(imagePath),
+ media_type: localImageMediaType(imagePath),
+ data: fs.readFileSync(imagePath).toString("base64"),
+ });
+ } catch (e) {
+ console.log(
+ `[Feishu] Failed to convert image ${imagePath} to base64: ${e}`,
+ );
+ }
+ }
+ const task = makeTask({
+ title: `[Feishu] ${title}`,
+ prompt: content,
+ working_dir,
+ schedule_type: ScheduleType.IMMEDIATE,
+ tags: "feishu",
+ image_paths,
+ prompt_images,
+ feishu_root_msg_id: message["message_id"] ?? null,
+ agent: resolve_agent("feishu", this.db),
+ });
+ const task_id = this.scheduler.submit_task(task);
+ const running_msg_id = await this._create_reply(
+ message["message_id"],
+ this._build_streaming_card(task_id, task.title, ""),
+ );
+ this._task_origin.set(task_id, [
+ reply_to,
+ message["message_id"],
+ message["message_id"],
+ ]);
+ this._root_msg_map.set(message["message_id"], task_id);
+ if (running_msg_id)
+ this._start_streaming(task_id, running_msg_id, task.title);
+ }
+}
diff --git a/backend/src/channels/slack.ts b/backend/src/channels/slack.ts
new file mode 100644
index 0000000..d82a940
--- /dev/null
+++ b/backend/src/channels/slack.ts
@@ -0,0 +1,1019 @@
+/**
+ * AgentForge Slack Channel — ported from channels/slack_channel.py.
+ *
+ * Listens for @bot mentions and DMs via Slack Socket Mode (no public IP
+ * required). Send any message to create a task; reply to a completion
+ * notification to resume. Slash commands (/status, /cancel, /resume) are also
+ * supported.
+ *
+ * Required environment variables:
+ * SLACK_BOT_TOKEN — xoxb-... bot token
+ * SLACK_APP_TOKEN — xapp-... app-level token (for Socket Mode)
+ *
+ * Porting notes:
+ * - Python used slack-sdk's WebClient + SocketModeClient. The TS port uses
+ * @slack/web-api + @slack/socket-mode, wrapped in thin adapters so the
+ * channel keeps the exact Python calling surface (chat_postMessage,
+ * reactions_add, conversations_open, auth_test,
+ * socket_mode_request_listeners, connect/disconnect). Tests inject fake
+ * client objects with the same surface.
+ * - Python's lazy `_require_slack()` import guard is mirrored: the dynamic
+ * import goes through the `_hooks.import_slack` test seam and import
+ * failures raise the same error message.
+ * - Python's daemon threads (reaction adds, notification sends) become
+ * fire-and-forget async functions; threading.Lock dropped (single-threaded
+ * event loop).
+ */
+
+import { Channel, OutboundMessageType } from "../bus.ts";
+import type { MessageBus, OutboundMessage, TaskDBLike } from "../bus.ts";
+import { makeTask, ScheduleType } from "../types.ts";
+import { handle_agent_command, resolve_agent } from "./agent_utils.ts";
+import type { SettingsDB } from "./agent_utils.ts";
+import { handle_dir_command, resolve_working_dir } from "./dir_utils.ts";
+import type { Task } from "../types.ts";
+
+// ── injected client surfaces (≙ slack_sdk WebClient / SocketModeClient) ──
+
+/** Structural view of the web client the channel talks to (Python names). */
+export interface WebClientLike {
+ auth_test(): Promise;
+ chat_postMessage(args: {
+ channel: string;
+ thread_ts?: string | null;
+ text: string;
+ mrkdwn?: boolean;
+ }): Promise;
+ reactions_add(args: {
+ channel: string;
+ timestamp: string;
+ name: string;
+ }): Promise;
+ conversations_open(args: { users: string }): Promise;
+}
+
+/** ≙ slack_sdk SocketModeRequest (type / envelope_id / payload). */
+export interface SocketModeRequestLike {
+ type: string;
+ envelope_id: string;
+ payload: Record;
+}
+
+/** The "client" passed to socket-mode listeners; used only to ack. */
+export interface SocketAckClientLike {
+ send_socket_mode_response(resp: { envelope_id: string }): void;
+}
+
+export type SocketModeRequestListener = (
+ client: SocketAckClientLike,
+ req: SocketModeRequestLike,
+) => void | Promise;
+
+/** Structural view of the socket-mode client (Python calling surface). */
+export interface SocketModeClientLike {
+ socket_mode_request_listeners: SocketModeRequestListener[];
+ connect(): void | Promise;
+ disconnect(): void | Promise;
+}
+
+/** What `_require_slack()` returns: the two client constructors. */
+export interface SlackSDK {
+ WebClient: new (token?: string) => WebClientLike;
+ SocketModeClient: new (opts: {
+ app_token?: string;
+ web_client?: WebClientLike | null;
+ }) => SocketModeClientLike;
+}
+
+// ── lazy import guard (≙ Python _require_slack) ───────────────────────────
+
+async function _import_slack(): Promise {
+ const webApi: any = await import("@slack/web-api");
+ const socketMode: any = await import("@slack/socket-mode");
+
+ /** Adapter exposing the Python slack_sdk WebClient surface. */
+ class WebClientAdapter implements WebClientLike {
+ private _client: any;
+
+ constructor(token?: string) {
+ this._client = new webApi.WebClient(token);
+ }
+
+ auth_test(): Promise {
+ return this._client.auth.test();
+ }
+
+ chat_postMessage(args: {
+ channel: string;
+ thread_ts?: string | null;
+ text: string;
+ mrkdwn?: boolean;
+ }): Promise {
+ return this._client.chat.postMessage({
+ channel: args.channel,
+ thread_ts: args.thread_ts ?? undefined,
+ text: args.text,
+ mrkdwn: args.mrkdwn,
+ });
+ }
+
+ reactions_add(args: {
+ channel: string;
+ timestamp: string;
+ name: string;
+ }): Promise {
+ return this._client.reactions.add({
+ channel: args.channel,
+ timestamp: args.timestamp,
+ name: args.name,
+ });
+ }
+
+ conversations_open(args: { users: string }): Promise {
+ return this._client.conversations.open({ users: args.users });
+ }
+ }
+
+ /**
+ * Adapter exposing the Python slack_sdk SocketModeClient surface
+ * (socket_mode_request_listeners + connect/disconnect) on top of
+ * @slack/socket-mode's EventEmitter API.
+ */
+ class SocketModeClientAdapter implements SocketModeClientLike {
+ socket_mode_request_listeners: SocketModeRequestListener[] = [];
+ private _client: any;
+
+ constructor(opts: {
+ app_token?: string;
+ web_client?: WebClientLike | null;
+ }) {
+ this._client = new socketMode.SocketModeClient({
+ appToken: opts.app_token,
+ });
+ this._client.on("slack_event", (args: any) => {
+ const ack_client: SocketAckClientLike = {
+ send_socket_mode_response: (_resp: { envelope_id: string }) => {
+ void Promise.resolve(args.ack()).catch((e: unknown) => {
+ console.log(`[Slack] ack failed: ${e}`);
+ });
+ },
+ };
+ const req: SocketModeRequestLike = {
+ type: args.type,
+ envelope_id: args.envelope_id,
+ payload: args.body ?? {},
+ };
+ for (const listener of this.socket_mode_request_listeners) {
+ void Promise.resolve(listener(ack_client, req)).catch(
+ (e: unknown) => {
+ console.log(`[Slack] socket request listener error: ${e}`);
+ },
+ );
+ }
+ });
+ }
+
+ async connect(): Promise {
+ await this._client.start();
+ }
+
+ async disconnect(): Promise {
+ await this._client.disconnect();
+ }
+ }
+
+ return {
+ WebClient: WebClientAdapter,
+ SocketModeClient: SocketModeClientAdapter,
+ };
+}
+
+// Test seam (≙ pytest monkeypatching the slack_sdk import / _require_slack).
+export const _hooks = { import_slack: _import_slack };
+
+export async function _require_slack(): Promise {
+ try {
+ return await _hooks.import_slack();
+ } catch (e) {
+ throw new Error(
+ "@slack/web-api and @slack/socket-mode are required for SlackChannel",
+ {
+ cause: e,
+ },
+ );
+ }
+}
+
+// ── help text (byte-identical to Python) ──────────────────────────────────
+
+export const HELP_TEXT = `*AgentForge Bot* 👋
+Send me any message and I'll create a task from it.
+Reply to a completion/failure notification to resume that task.
+
+*Commands:*
+• \`/status \` — show task details
+• \`/cancel \` — cancel a task
+• \`/resume \` — resume a task with a message
+• \`/dir \` — set default working directory
+ e.g. \`/dir ~/workspace/myproject\`
+• \`/agent \` — switch coding agent (\`claude\` / \`codex\`)
+• \`/help\` — show this message
+
+*Tips:*
+• You can also mention a path in your message and it will be used automatically.
+ e.g. _在 ~/myapp 里帮我修复登录 bug_
+• Reply to any result notification to continue the conversation.
+`;
+
+// ── structural dependencies ───────────────────────────────────────────────
+
+/** Minimal structural view of TaskDB used by this channel. */
+export interface SlackTaskDB extends TaskDBLike, SettingsDB {
+ update_task(task_id: number, updates: Record): void;
+}
+
+/**
+ * Minimal structural view of TaskScheduler used by this channel
+ * (≙ Python `TaskScheduler.submit_task(task) -> int`).
+ */
+export interface SchedulerLike {
+ submit_task(task: Task): number;
+}
+
+// ── channel ───────────────────────────────────────────────────────────────
+
+/** Slack channel integration using Socket Mode. */
+export class SlackChannel extends Channel {
+ declare db: SlackTaskDB;
+ scheduler: SchedulerLike;
+
+ bot_token: string;
+ app_token: string;
+
+ _web_client: WebClientLike | null = null;
+ _socket_client: SocketModeClientLike | null = null;
+ _bot_user_id: string | null = null;
+
+ // Maps task_id → [channel_id, thread_ts, reaction_ts] for reply-back on completion
+ // thread_ts: used for posting reply messages in the correct thread
+ // reaction_ts: used for adding emoji reactions (may differ from thread_ts on resume)
+ _task_origin: Map = new Map();
+
+ // DM channel cache for P2P fallback (user_id → DM channel_id)
+ _dm_channel_cache: Map = new Map();
+
+ // Maps notification thread_ts → task_id for resume-by-reply
+ _notification_map: Map = new Map();
+
+ // Maps thread root ts → task_id for thread-based session resume
+ _thread_ts_map: Map = new Map();
+
+ constructor(
+ bus: MessageBus,
+ db: SlackTaskDB,
+ scheduler: SchedulerLike,
+ bot_token: string = "",
+ app_token: string = "",
+ ) {
+ super("slack", bus, db);
+ this.scheduler = scheduler;
+
+ this.bot_token = bot_token || process.env.SLACK_BOT_TOKEN || "";
+ this.app_token = app_token || process.env.SLACK_APP_TOKEN || "";
+
+ // Subscribe to outbound bus messages for task notifications
+ bus.subscribe_outbound(this._on_outbound);
+
+ console.log(
+ `[Slack] Initialized. bot_token=${this.bot_token ? "set" : "MISSING"}, ` +
+ `app_token=${this.app_token ? "set" : "MISSING"}`,
+ );
+ }
+
+ // ── lifecycle ────────────────────────────────────────────────
+
+ async start(): Promise {
+ if (!this.bot_token || !this.app_token) {
+ console.log(
+ "[Slack] Missing SLACK_BOT_TOKEN or SLACK_APP_TOKEN — channel disabled",
+ );
+ return;
+ }
+
+ console.log("[Slack] Starting... importing slack_sdk");
+ const { WebClient, SocketModeClient } = await _require_slack();
+ console.log("[Slack] slack_sdk imported successfully");
+
+ this._web_client = new WebClient(this.bot_token);
+ console.log("[Slack] WebClient created, calling auth_test()...");
+
+ // Resolve bot's own user ID so we can ignore self-messages
+ try {
+ const resp = await this._web_client.auth_test();
+ this._bot_user_id = resp["user_id"];
+ console.log(
+ `[Slack] auth_test OK — bot_user_id=${this._bot_user_id}, ` +
+ `team=${resp["team"] ?? "?"}, user=${resp["user"] ?? "?"}`,
+ );
+ } catch (e) {
+ console.log(`[Slack] auth_test FAILED: ${e}`);
+ console.error((e as Error)?.stack ?? e);
+ return;
+ }
+
+ console.log("[Slack] Creating SocketModeClient...");
+ this._socket_client = new SocketModeClient({
+ app_token: this.app_token,
+ web_client: this._web_client,
+ });
+
+ // Register event handler
+ this._socket_client.socket_mode_request_listeners.push(
+ this._handle_socket_request,
+ );
+ console.log("[Slack] Registered socket_mode_request_listeners");
+
+ this._running = true;
+ console.log("[Slack] Calling socket_client.connect()...");
+ await this._socket_client.connect();
+ console.log("[Slack] Socket Mode connected — listening for events");
+ }
+
+ stop(): void {
+ console.log("[Slack] Stopping...");
+ this._running = false;
+ this.bus.unsubscribe_outbound(this._on_outbound);
+ if (this._socket_client) {
+ try {
+ void Promise.resolve(this._socket_client.disconnect()).catch(() => {});
+ } catch {
+ /* ignore */
+ }
+ }
+ console.log("[Slack] Stopped");
+ }
+
+ // ── socket mode handler ──────────────────────────────────────
+
+ // Arrow property: a stable bound reference is pushed into
+ // socket_mode_request_listeners (≙ Python bound method).
+ _handle_socket_request = async (
+ client: SocketAckClientLike,
+ req: SocketModeRequestLike,
+ ): Promise => {
+ console.log(
+ `[Slack] <<< Socket request received: type=${req.type}, ` +
+ `envelope_id=${req.envelope_id.slice(0, 12)}...`,
+ );
+
+ // Acknowledge immediately (Slack requires < 3s ack)
+ client.send_socket_mode_response({ envelope_id: req.envelope_id });
+ console.log(
+ `[Slack] ACK sent for envelope ${req.envelope_id.slice(0, 12)}`,
+ );
+
+ if (req.type !== "events_api") {
+ console.log(
+ `[Slack] Ignoring non-events_api request type: ${req.type}`,
+ );
+ return;
+ }
+
+ const payload = req.payload ?? {};
+ const event: Record = payload["event"] ?? {};
+ const event_type: string = event["type"] ?? "";
+
+ console.log(
+ `[Slack] Event type=${event_type}, ` +
+ `user=${event["user"] ?? "?"}, ` +
+ `channel=${event["channel"] ?? "?"}, ` +
+ `channel_type=${event["channel_type"] ?? "?"}, ` +
+ `bot_id=${event["bot_id"] ?? "none"}, ` +
+ `subtype=${event["subtype"] ?? "none"}, ` +
+ `text=${JSON.stringify((event["text"] ?? "").slice(0, 80))}`,
+ );
+
+ // Handle message events
+ try {
+ if (event_type === "message") {
+ await this._handle_message_event(event);
+ } else if (event_type === "app_mention") {
+ await this._handle_mention_event(event);
+ } else if (event_type === "app_home_opened") {
+ await this._handle_app_home_opened(event);
+ } else {
+ console.log(`[Slack] Unhandled event type: ${event_type}`);
+ }
+ } catch (e) {
+ console.log(`[Slack] ERROR handling ${event_type} event: ${e}`);
+ console.error((e as Error)?.stack ?? e);
+ }
+ };
+
+ /** Handle DM messages (channel_type == 'im'). */
+ async _handle_message_event(event: Record): Promise {
+ // Only process DMs, not channel messages (those come via app_mention)
+ if (event["channel_type"] !== "im") {
+ console.log(
+ `[Slack] message event skipped: channel_type=${JSON.stringify(
+ event["channel_type"],
+ )} (not 'im')`,
+ );
+ return;
+ }
+ // Ignore bot messages and message edits
+ if (event["bot_id"] || event["subtype"]) {
+ console.log(
+ `[Slack] message event skipped: bot_id=${event["bot_id"]}, ` +
+ `subtype=${event["subtype"]}`,
+ );
+ return;
+ }
+
+ const user_id: string = event["user"] ?? "";
+ if (user_id === this._bot_user_id) {
+ console.log("[Slack] message event skipped: message from self (bot)");
+ return;
+ }
+
+ const text: string = (event["text"] ?? "").trim();
+ const channel_id: string = event["channel"] ?? "";
+ const thread_ts: string | null = event["thread_ts"] ?? null;
+ const msg_ts: string = event["ts"];
+
+ console.log(
+ `[Slack] DM from user=${user_id}, channel=${channel_id}, ` +
+ `text=${JSON.stringify(text.slice(0, 80))}`,
+ );
+ await this._handle_user_message(text, channel_id, thread_ts, msg_ts);
+ }
+
+ /** Handle @bot mentions in channels. */
+ async _handle_mention_event(event: Record): Promise {
+ if (event["bot_id"] || event["subtype"]) {
+ console.log(
+ `[Slack] mention event skipped: bot_id=${event["bot_id"]}, ` +
+ `subtype=${event["subtype"]}`,
+ );
+ return;
+ }
+
+ const user_id: string = event["user"] ?? "";
+ if (user_id === this._bot_user_id) {
+ console.log("[Slack] mention event skipped: mention from self (bot)");
+ return;
+ }
+
+ // Strip the mention prefix (<@BOTID> ...) from the text
+ let text: string = (event["text"] ?? "").trim();
+ if (this._bot_user_id) {
+ const mention_prefix = `<@${this._bot_user_id}>`;
+ if (text.startsWith(mention_prefix)) {
+ text = text.slice(mention_prefix.length).trim();
+ }
+ }
+
+ const channel_id: string = event["channel"] ?? "";
+ const thread_ts: string | null = event["thread_ts"] ?? null;
+ const msg_ts: string = event["ts"];
+
+ console.log(
+ `[Slack] Mention from user=${user_id}, channel=${channel_id}, ` +
+ `text=${JSON.stringify(text.slice(0, 80))}`,
+ );
+ await this._handle_user_message(text, channel_id, thread_ts, msg_ts);
+ }
+
+ /** Handle app_home_opened event — send HELP_TEXT DM on first open. */
+ async _handle_app_home_opened(event: Record): Promise {
+ const user_id: string = event["user"] ?? "";
+ if (!user_id || user_id === this._bot_user_id) {
+ return;
+ }
+ // Only send on the first open (tab == "home", not "messages")
+ const tab: string = event["tab"] ?? "";
+ if (tab !== "home") {
+ return;
+ }
+ console.log(
+ `[Slack] app_home_opened by user=${user_id}, sending HELP_TEXT via DM`,
+ );
+ const dm_ch = await this._open_dm_channel(user_id);
+ if (dm_ch) {
+ await this._reply(dm_ch, null, HELP_TEXT);
+ } else {
+ console.log(`[Slack] Failed to open DM channel with user ${user_id}`);
+ }
+ }
+
+ // ── unified message handler ───────────────────────────────────
+
+ /** Handle any user message: commands, resume-by-reply, or create task. */
+ async _handle_user_message(
+ text: string,
+ channel_id: string,
+ thread_ts: string | null,
+ msg_ts: string,
+ ): Promise {
+ if (!text) {
+ await this._reply(channel_id, msg_ts, HELP_TEXT);
+ return;
+ }
+
+ // ── slash commands ────────────────────────────────────────
+ if (text.startsWith("/")) {
+ const ws = text.search(/\s/);
+ const cmd = (ws === -1 ? text : text.slice(0, ws)).toLowerCase();
+ const args = ws === -1 ? "" : text.slice(ws + 1).trim();
+
+ if (cmd === "/status") {
+ await this._cmd_status(args, channel_id, msg_ts);
+ } else if (cmd === "/cancel") {
+ await this._cmd_cancel(args, channel_id, msg_ts);
+ } else if (cmd === "/resume") {
+ await this._cmd_resume(args, channel_id, msg_ts);
+ } else if (cmd === "/dir" || cmd === "/cd") {
+ const reply = handle_dir_command(text, "slack", this.db);
+ await this._reply(channel_id, msg_ts, reply ? reply : HELP_TEXT);
+ } else if (cmd === "/agent") {
+ const reply = handle_agent_command(text, "slack", this.db);
+ await this._reply(channel_id, msg_ts, reply ? reply : HELP_TEXT);
+ } else if (cmd === "/help") {
+ await this._reply(channel_id, msg_ts, HELP_TEXT);
+ } else {
+ await this._reply(channel_id, msg_ts, HELP_TEXT);
+ }
+ return;
+ }
+
+ if (text.toLowerCase() === "help") {
+ await this._reply(channel_id, msg_ts, HELP_TEXT);
+ return;
+ }
+
+ // ── reply to a notification thread → resume task ──────────
+ if (thread_ts) {
+ // Look up task_id: first from notification_map, then from thread_ts_map
+ let task_id = this._notification_map.get(thread_ts);
+ if (!task_id) {
+ task_id = this._thread_ts_map.get(thread_ts);
+ }
+ if (task_id) {
+ const task = this.db.get_task(task_id);
+ if (task && task["session_id"]) {
+ this.db.update_task(task_id, {
+ status: "pending",
+ prompt: text,
+ result: null,
+ error: null,
+ question: null,
+ });
+ this._task_origin.set(task_id, [channel_id, thread_ts, msg_ts]);
+ this._add_reaction(channel_id, msg_ts, "eyes");
+ await this._reply(channel_id, thread_ts, ":arrow_forward:");
+ console.log(
+ `[Slack] Auto-resuming task ${task_id} from thread reply`,
+ );
+ return;
+ } else {
+ await this._reply(
+ channel_id,
+ thread_ts,
+ `:x: Task *#${task_id}* has no saved session to resume.`,
+ );
+ return;
+ }
+ }
+ }
+
+ // ── default: create a new task from message text ──────────
+ await this._create_task(text, channel_id, msg_ts);
+ }
+
+ // ── task creation ─────────────────────────────────────────────
+
+ /** Create a new task from any message. */
+ async _create_task(
+ text: string,
+ channel_id: string,
+ thread_ts: string,
+ ): Promise {
+ const title = text.slice(0, 60) + (text.length > 60 ? "…" : "");
+
+ const task = makeTask({
+ title: `[Slack] ${title}`,
+ prompt: text,
+ working_dir: await resolve_working_dir(text, "slack", this.db),
+ schedule_type: ScheduleType.IMMEDIATE,
+ tags: "slack",
+ agent: resolve_agent("slack", this.db),
+ });
+ const task_id = this.scheduler.submit_task(task);
+ console.log(`[Slack] Task #${task_id} created from message`);
+
+ this._task_origin.set(task_id, [channel_id, thread_ts, thread_ts]);
+ console.log(
+ `[Slack] Origin set for task #${task_id}: channel=${channel_id}, ` +
+ `thread_ts=${thread_ts}`,
+ );
+
+ // Track thread root ts → task_id so replies in the thread can resume
+ this._thread_ts_map.set(thread_ts, task_id);
+
+ // Acknowledge with an eyes reaction and a brief running hint
+ this._add_reaction(channel_id, thread_ts, "eyes");
+ await this._reply(channel_id, thread_ts, `Task #${task_id} is running…`);
+ }
+
+ // ── commands ──────────────────────────────────────────────────
+
+ async _cmd_status(
+ args: string,
+ channel_id: string,
+ thread_ts: string,
+ ): Promise {
+ const task_id = SlackChannel._parse_task_id(args);
+ if (task_id === null) {
+ await this._reply(
+ channel_id,
+ thread_ts,
+ ":warning: Usage: `/status `",
+ );
+ return;
+ }
+
+ const task = this.db.get_task(task_id);
+ if (!task) {
+ await this._reply(
+ channel_id,
+ thread_ts,
+ `:x: Task #${task_id} not found.`,
+ );
+ return;
+ }
+
+ const status_emoji: Record = {
+ pending: ":hourglass:",
+ scheduled: ":calendar:",
+ running: ":runner:",
+ completed: ":white_check_mark:",
+ failed: ":x:",
+ cancelled: ":no_entry_sign:",
+ };
+ const emoji = status_emoji[task["status"] as string] ?? ":grey_question:";
+ const lines = [
+ `${emoji} *Task #${task["id"]}* — *${task["title"]}*`,
+ `Status: \`${task["status"]}\``,
+ `Created: ${task["created_at"] ?? "—"}`,
+ `Last run: ${task["last_run_at"] || "—"}`,
+ ];
+ if (task["error"]) {
+ lines.push(`Error: \`${(task["error"] as string).slice(0, 300)}\``);
+ }
+ if (task["result"]) {
+ lines.push(`Result: ${(task["result"] as string).slice(0, 500)}`);
+ }
+
+ await this._reply(channel_id, thread_ts, lines.join("\n"));
+ }
+
+ async _cmd_cancel(
+ args: string,
+ channel_id: string,
+ thread_ts: string,
+ ): Promise {
+ const task_id = SlackChannel._parse_task_id(args);
+ if (task_id === null) {
+ await this._reply(
+ channel_id,
+ thread_ts,
+ ":warning: Usage: `/cancel `",
+ );
+ return;
+ }
+
+ const task = this.db.get_task(task_id);
+ if (!task) {
+ await this._reply(
+ channel_id,
+ thread_ts,
+ `:x: Task #${task_id} not found.`,
+ );
+ return;
+ }
+
+ const status = task["status"] as string;
+ if (
+ status === "completed" ||
+ status === "failed" ||
+ status === "cancelled"
+ ) {
+ await this._reply(
+ channel_id,
+ thread_ts,
+ `:information_source: Task #${task_id} is already \`${status}\`.`,
+ );
+ return;
+ }
+
+ this.db.update_task(task_id, { status: "cancelled" });
+ await this._reply(
+ channel_id,
+ thread_ts,
+ `:no_entry_sign: Task #${task_id} cancelled.`,
+ );
+ }
+
+ async _cmd_resume(
+ args: string,
+ channel_id: string,
+ thread_ts: string,
+ ): Promise {
+ // ≙ Python args.strip().split(" ", 1)
+ const stripped = args.trim();
+ const sp = stripped.indexOf(" ");
+ const head = sp === -1 ? stripped : stripped.slice(0, sp);
+ const tail = sp === -1 ? null : stripped.slice(sp + 1);
+
+ if (!/^[+-]?\d+$/.test(head.replace(/^#+/, ""))) {
+ await this._reply(
+ channel_id,
+ thread_ts,
+ ":warning: Usage: `/resume `",
+ );
+ return;
+ }
+
+ const tid = parseInt(head.replace(/^#+/, ""), 10);
+ const resume_msg = tail !== null ? tail.trim() : "";
+ if (!resume_msg) {
+ await this._reply(
+ channel_id,
+ thread_ts,
+ ":warning: Please provide a message to resume with.",
+ );
+ return;
+ }
+
+ const task = this.db.get_task(tid);
+ if (!task || !task["session_id"]) {
+ await this._reply(
+ channel_id,
+ thread_ts,
+ `:x: Task #${tid} not found or has no saved session.`,
+ );
+ return;
+ }
+
+ this.db.update_task(tid, {
+ status: "pending",
+ prompt: resume_msg,
+ result: null,
+ error: null,
+ question: null,
+ });
+ this._task_origin.set(tid, [channel_id, thread_ts, thread_ts]);
+ this._add_reaction(channel_id, thread_ts, "eyes");
+ await this._reply(channel_id, thread_ts, ":arrow_forward:");
+ }
+
+ // ── Channel ABC: send outbound message ───────────────────────
+
+ /** Forward task completion/failure to the originating Slack thread. */
+ send(msg: OutboundMessage): void {
+ // Python's send() is synchronous (its network calls run on threads); the
+ // TS port keeps a synchronous signature and runs the async body
+ // fire-and-forget.
+ void this._send(msg).catch((e) => {
+ console.log(`[Slack] send() error: ${e}`);
+ });
+ }
+
+ private async _send(msg: OutboundMessage): Promise {
+ if (
+ msg.type !== OutboundMessageType.TASK_COMPLETED &&
+ msg.type !== OutboundMessageType.TASK_FAILED
+ ) {
+ return;
+ }
+
+ const task_id = msg.task_id;
+ const origin = this._task_origin.get(task_id);
+ console.log(
+ `[Slack] send() task_id=${task_id} (type=${typeof task_id}), ` +
+ `origin=${JSON.stringify(origin ?? null)}, ` +
+ `keys=${JSON.stringify([...this._task_origin.keys()])}`,
+ );
+
+ // Build notification text
+ let title: string;
+ let text: string;
+ if (msg.type === OutboundMessageType.TASK_COMPLETED) {
+ const result_text = ((msg.payload["result"] as string | null) || "")
+ .trim()
+ .slice(0, 10000);
+ title = (msg.payload["title"] as string | null) || `Task #${task_id}`;
+ text = result_text || "Done.";
+ } else {
+ const error_text = (
+ (msg.payload["error"] as string | null) || "Unknown error"
+ )
+ .trim()
+ .slice(0, 800);
+ title = (msg.payload["title"] as string | null) || `Task #${task_id}`;
+ text = error_text;
+ }
+
+ let channel_id: string;
+ let thread_ts: string | null;
+ if (origin) {
+ const [origin_channel, origin_thread, reaction_ts] = origin;
+ channel_id = origin_channel;
+ thread_ts = origin_thread;
+ // Add emoji reaction to the message that triggered the task (or resume)
+ const react_target = (reaction_ts || thread_ts) as string;
+ if (msg.type === OutboundMessageType.TASK_COMPLETED) {
+ this._add_reaction(channel_id, react_target, "white_check_mark");
+ } else {
+ this._add_reaction(channel_id, react_target, "x");
+ }
+ } else {
+ // Fallback: P2P DM to configured user, or default channel
+ const dm_user = this.db.get_setting("slack_default_user");
+ const default_channel = this.db.get_setting("slack_default_channel");
+ if (dm_user) {
+ const dm_ch = await this._open_dm_channel(dm_user);
+ if (dm_ch) {
+ channel_id = dm_ch;
+ thread_ts = null;
+ const status_emoji =
+ msg.type === OutboundMessageType.TASK_COMPLETED
+ ? ":white_check_mark:"
+ : ":x:";
+ text = `${status_emoji} *${title}*\n${text}`;
+ console.log(`[Slack] Falling back to P2P DM with user ${dm_user}`);
+ } else {
+ console.log(
+ `[Slack] Failed to open DM with user ${dm_user}, skipping`,
+ );
+ return;
+ }
+ } else if (default_channel) {
+ channel_id = default_channel;
+ thread_ts = null;
+ const status_emoji =
+ msg.type === OutboundMessageType.TASK_COMPLETED
+ ? ":white_check_mark:"
+ : ":x:";
+ text = `${status_emoji} *${title}*\n${text}`;
+ } else {
+ console.log(
+ `[Slack] No origin, no known user, no slack_default_channel for ` +
+ `task #${task_id}, skipping`,
+ );
+ return;
+ }
+ }
+
+ console.log(
+ `[Slack] Sending outbound notification for task #${task_id}: ${msg.type}`,
+ );
+
+ // ≙ Python's _send_and_track daemon thread
+ const send_channel = channel_id;
+ const send_thread = thread_ts;
+ const send_text = text;
+ void (async () => {
+ const sent_ts = await this._reply_return_ts(
+ send_channel,
+ send_thread,
+ send_text,
+ );
+ if (sent_ts) {
+ this._notification_map.set(sent_ts, task_id);
+ console.log(
+ `[Slack] Notification ts=${sent_ts} mapped to task #${task_id}`,
+ );
+ }
+ })();
+
+ // Free origin memory after terminal state
+ this._task_origin.delete(task_id);
+ }
+
+ /** MessageBus outbound subscriber callback (stable bound reference). */
+ _on_outbound = (msg: OutboundMessage): void => {
+ this.send(msg);
+ };
+
+ // ── helpers ──────────────────────────────────────────────────
+
+ /** Open (or retrieve cached) DM channel with a user. */
+ async _open_dm_channel(user_id: string): Promise {
+ const cached = this._dm_channel_cache.get(user_id);
+ if (cached !== undefined) {
+ return cached;
+ }
+ if (!this._web_client) {
+ return null;
+ }
+ try {
+ const resp = await this._web_client.conversations_open({
+ users: user_id,
+ });
+ const ch_id: string = resp["channel"]["id"];
+ this._dm_channel_cache.set(user_id, ch_id);
+ console.log(`[Slack] Opened DM channel ${ch_id} with user ${user_id}`);
+ return ch_id;
+ } catch (e) {
+ console.log(
+ `[Slack] conversations.open failed for user ${user_id}: ${e}`,
+ );
+ return null;
+ }
+ }
+
+ /** Add an emoji reaction to a message (non-blocking). */
+ _add_reaction(channel_id: string, timestamp: string, emoji: string): void {
+ const web_client = this._web_client;
+ if (!web_client) {
+ return;
+ }
+
+ // ≙ Python daemon thread running _do()
+ void (async () => {
+ try {
+ await web_client.reactions_add({
+ channel: channel_id,
+ timestamp,
+ name: emoji,
+ });
+ } catch (e) {
+ if (String(e).includes("already_reacted")) {
+ // Reaction already exists, ignore
+ } else {
+ console.log(`[Slack] Failed to add reaction ${emoji}: ${e}`);
+ }
+ }
+ })();
+ }
+
+ async _reply(
+ channel_id: string,
+ thread_ts: string | null,
+ text: string,
+ ): Promise {
+ if (!this._web_client) {
+ console.log("[Slack] _reply skipped: no web_client");
+ return;
+ }
+ try {
+ console.log(
+ `[Slack] >>> Sending message to channel=${channel_id}, ` +
+ `thread=${thread_ts}, len=${text.length}`,
+ );
+ await this._web_client.chat_postMessage({
+ channel: channel_id,
+ thread_ts,
+ text,
+ mrkdwn: true,
+ });
+ console.log("[Slack] >>> Message sent OK");
+ } catch (e) {
+ console.log(`[Slack] >>> chat_postMessage FAILED: ${e}`);
+ console.error((e as Error)?.stack ?? e);
+ }
+ }
+
+ /** Send a message and return its ts (for tracking notification threads). */
+ async _reply_return_ts(
+ channel_id: string,
+ thread_ts: string | null,
+ text: string,
+ ): Promise {
+ if (!this._web_client) {
+ return null;
+ }
+ try {
+ const resp = await this._web_client.chat_postMessage({
+ channel: channel_id,
+ thread_ts,
+ text,
+ mrkdwn: true,
+ });
+ return resp?.["ts"] ?? null;
+ } catch (e) {
+ console.log(`[Slack] >>> chat_postMessage FAILED: ${e}`);
+ console.error((e as Error)?.stack ?? e);
+ return null;
+ }
+ }
+
+ static _parse_task_id(s: string): number | null {
+ const stripped = s.trim().replace(/^#+/, "");
+ if (!/^[+-]?\d+$/.test(stripped)) {
+ return null;
+ }
+ return parseInt(stripped, 10);
+ }
+}
diff --git a/backend/src/channels/telegram.ts b/backend/src/channels/telegram.ts
new file mode 100644
index 0000000..ba5f887
--- /dev/null
+++ b/backend/src/channels/telegram.ts
@@ -0,0 +1,884 @@
+/**
+ * Telegram channel for AgentForge — ported from channels/telegram_channel.py.
+ *
+ * Send any message to create a task. Reply to a completion/failure notification
+ * to resume that task. Slash commands also supported:
+ *
+ * /help — show help
+ * /status — task details
+ * /cancel — cancel a task
+ * /resume — resume a task with a message
+ * /dir — set default working directory
+ *
+ * When a task completes or fails the bot sends a notification to the chat where
+ * the task was created.
+ *
+ * Configuration via environment variables:
+ * TELEGRAM_BOT_TOKEN — required, bot token from @BotFather
+ * TELEGRAM_ALLOWED_USERS — optional, comma-separated Telegram user IDs
+ * (numeric). When set, any other user is rejected.
+ *
+ * Porting notes
+ * ─────────────
+ * The Python original uses the python-telegram-bot SDK (Application + Update +
+ * polling on a dedicated thread/event loop). This port talks to the raw
+ * Telegram Bot API over fetch instead:
+ * - a long-polling loop calls getUpdates with offset/timeout and routes
+ * message updates to the same handler logic as the Python handlers;
+ * - sending uses sendMessage / setMessageReaction directly.
+ * The HTTP transport is an injectable seam (`_api`, default = fetch against
+ * https://api.telegram.org/bot/) so tests can intercept calls
+ * exactly where the pytest suite mocked `Application.bot`. Method names and
+ * user-facing strings are kept byte-identical to the Python source.
+ *
+ * Threading-model mapping:
+ * _loop / _thread → _poll_promise (the async polling loop)
+ * _loop_ready (Event) → _ready (boolean, set when the loop starts)
+ * _app (Application) → _api (TelegramApi seam; null ≙ missing app)
+ * asyncio.run_coroutine_threadsafe(coro) → plain `await` (single runtime)
+ */
+
+import {
+ Channel,
+ MessageBus,
+ OutboundMessageType,
+ type OutboundMessage,
+ type TaskDBLike,
+} from "../bus.ts";
+import { makeTask, ScheduleType, type Task } from "../types.ts";
+import {
+ handle_agent_command,
+ resolve_agent,
+ type SettingsDB,
+} from "./agent_utils.ts";
+import { handle_dir_command, resolve_working_dir } from "./dir_utils.ts";
+
+// ≙ Python's try/except ImportError guard around the telegram SDK import.
+// fetch is always available on Bun, so this is true by default; the setter is
+// the test seam matching `monkeypatch.setattr(telegram_channel,
+// "TELEGRAM_AVAILABLE", False)`.
+export let TELEGRAM_AVAILABLE = true;
+
+export function _set_telegram_available(value: boolean): void {
+ TELEGRAM_AVAILABLE = value;
+}
+
+export const HELP_TEXT =
+ "👋 *AgentForge Bot*\n\n" +
+ "Send me any message and I'll create a task from it\\.\n" +
+ "Reply to a completion/failure notification to resume that task\\.\n\n" +
+ "*Commands:*\n" +
+ "/status `` — task details\n" +
+ "/cancel `` — cancel a task\n" +
+ "/resume ` ` — resume a task\n" +
+ "/dir `` — set default working directory\n" +
+ " e\\.g\\. `/dir ~/workspace/myproject`\n" +
+ "/agent `` — switch coding agent \\(`claude` / `codex`\\)\n" +
+ "/help — show this message\n\n" +
+ "*Tips:*\n" +
+ "• You can also mention a path in your message and it will be used automatically\\.\n" +
+ " e\\.g\\. _在 ~/myapp 里帮我修复登录 bug_\n" +
+ "• Reply to any result notification to continue the conversation\\.";
+
+// ── Bot-API-shaped update types ───────────────────────────────────
+
+export interface TgUser {
+ id: number;
+ username?: string | null;
+ first_name?: string | null;
+ last_name?: string | null;
+}
+
+export interface TgChat {
+ id: number | string;
+ type?: string | null;
+ title?: string | null;
+ username?: string | null;
+}
+
+export interface TgMessage {
+ message_id: number;
+ text?: string | null;
+ chat: TgChat;
+ from?: TgUser | null; // sender (≙ update.effective_user)
+ reply_to_message?: { message_id: number } | null;
+ forward_from?: TgUser | null;
+ forward_from_chat?: TgChat | null;
+ forward_date?: number | null;
+}
+
+export interface TgUpdate {
+ update_id?: number;
+ message?: TgMessage | null;
+}
+
+/** ≙ python-telegram-bot's `context` — only `args` is ever used. */
+export interface TgContext {
+ args: string[];
+}
+
+// ── structural dependency interfaces ──────────────────────────────
+
+/** Minimal structural view of TaskDB used by this channel. */
+export interface TelegramDB extends TaskDBLike, SettingsDB {
+ update_task(task_id: number, updates: Record): void;
+}
+
+/**
+ * Minimal structural view of TaskScheduler (do NOT import scheduler.ts —
+ * the channel only ever calls submit_task).
+ */
+export interface TelegramScheduler {
+ submit_task(task: Task): number;
+}
+
+// ── injectable HTTP seam ──────────────────────────────────────────
+
+/**
+ * Calls a Telegram Bot API method and resolves with its `result` payload.
+ * Rejects on transport errors or when the API answers `ok: false`.
+ */
+export type TelegramApi = (
+ method: string,
+ params?: Record,
+) => Promise;
+
+/** Default TelegramApi implementation: fetch against api.telegram.org. */
+export function make_fetch_api(token: string): TelegramApi {
+ return async (method: string, params: Record = {}) => {
+ const resp = await fetch(`https://api.telegram.org/bot${token}/${method}`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(params),
+ });
+ const data = (await resp.json()) as {
+ ok?: boolean;
+ result?: unknown;
+ description?: string;
+ };
+ if (!data.ok) {
+ throw new Error(
+ `Telegram API ${method} failed: ${data.description ?? `HTTP ${resp.status}`}`,
+ );
+ }
+ return data.result;
+ };
+}
+
+function sleep(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+// ── TelegramChannel ───────────────────────────────────────────────
+
+export class TelegramChannel extends Channel {
+ declare db: TelegramDB;
+ scheduler: TelegramScheduler;
+ _token: string;
+ _allowed_users: Set;
+
+ /** HTTP seam (≙ self._app / self._app.bot); null ≙ app not built. */
+ _api: TelegramApi | null;
+ /** ≙ self._loop_ready (threading.Event). */
+ _ready = false;
+ /** ≙ self._thread — the long-polling loop's promise. */
+ _poll_promise: Promise | null = null;
+ /** getUpdates offset (next update_id to fetch). */
+ _offset = 0;
+
+ // Maps task_id → (chat_id, reply_message_id, reaction_message_id) for reply-back and reactions
+ // reply_message_id: used for reply_to_message_id when posting completion
+ // reaction_message_id: used for adding emoji reactions (may differ on resume)
+ _task_origin: Map = new Map();
+
+ // Maps notification message_id → task_id for resume-by-reply
+ _notification_map: Map = new Map();
+
+ /** Slash-command routing table (≙ the CommandHandler registrations). */
+ readonly _command_handlers: Record<
+ string,
+ (update: TgUpdate, context: TgContext) => Promise
+ >;
+
+ constructor(
+ bus: MessageBus,
+ db: TelegramDB,
+ scheduler: TelegramScheduler,
+ token: string,
+ allowed_users: number[] | null = null,
+ ) {
+ super("telegram", bus, db);
+ this.scheduler = scheduler;
+ this._token = token;
+ this._allowed_users = new Set(allowed_users ?? []);
+ this._api = make_fetch_api(token);
+
+ this._command_handlers = {
+ start: (u, c) => this._cmd_help(u, c),
+ help: (u, c) => this._cmd_help(u, c),
+ status: (u, c) => this._cmd_status(u, c),
+ cancel: (u, c) => this._cmd_cancel(u, c),
+ resume: (u, c) => this._cmd_resume(u, c),
+ };
+
+ // Subscribe to bus so send() is called on task updates
+ bus.subscribe_outbound(this._on_outbound);
+ }
+
+ // ── lifecycle ────────────────────────────────────────────────
+
+ start(): void {
+ if (!TELEGRAM_AVAILABLE) {
+ console.log(
+ "[Telegram] Telegram Bot API transport unavailable in this runtime",
+ );
+ return;
+ }
+ this._running = true;
+ this._poll_promise = this._run_bot();
+ console.log("[Telegram] Bot thread started");
+ }
+
+ stop(): void {
+ console.log("[Telegram] Stopping bot…");
+ this._running = false;
+ this.bus.unsubscribe_outbound(this._on_outbound);
+ console.log("[Telegram] Bot stopped");
+ }
+
+ // ── Channel ABC: send outbound message ───────────────────────
+
+ /**
+ * MessageBus outbound subscriber callback. Arrow-function property so the
+ * reference passed to subscribe/unsubscribe is stable (≙ bound method).
+ */
+ _on_outbound = (msg: OutboundMessage): void => {
+ void this.send(msg);
+ };
+
+ /** Forward a task completion/failure notification to the originating chat. */
+ async send(msg: OutboundMessage): Promise {
+ if (!this._running) return;
+ if (
+ msg.type !== OutboundMessageType.TASK_COMPLETED &&
+ msg.type !== OutboundMessageType.TASK_FAILED
+ ) {
+ return;
+ }
+ if (!this._ready) {
+ console.log(
+ "[Telegram] send() called before event loop ready, dropping message",
+ );
+ return;
+ }
+ if (!this._api) return;
+ const api = this._api;
+
+ const task_id = msg.task_id;
+ const origin = this._task_origin.get(task_id);
+
+ const title =
+ (msg.payload["title"] as string | null | undefined) || `Task #${task_id}`;
+
+ const is_completed = msg.type === OutboundMessageType.TASK_COMPLETED;
+ let body: string;
+ if (is_completed) {
+ let result_text = (
+ (msg.payload["result"] as string | null | undefined) || ""
+ ).trim();
+ if (result_text.length > 10000) {
+ result_text = result_text.slice(0, 10000) + "\n…(truncated)";
+ }
+ body = result_text || "Done.";
+ } else {
+ let error_text = (
+ (msg.payload["error"] as string | null | undefined) || "Unknown error"
+ ).trim();
+ // Smart truncation: keep beginning (most informative) and signal cut
+ if (error_text.length > 800) {
+ error_text =
+ error_text.slice(0, 800) +
+ "\n…(truncated — use /status for full details)";
+ }
+ body = error_text;
+ }
+
+ let chat_id: number | string;
+ let orig_message_id: number | null = null;
+ let reaction_message_id: number | null = null;
+ let text: string;
+ if (origin) {
+ [chat_id, orig_message_id, reaction_message_id] = origin;
+ // Always include task title and status emoji for clarity
+ const status_emoji = is_completed ? "✅" : "❌";
+ text = `${status_emoji} Task #${task_id}: ${title}\n${body}`;
+ if (!is_completed) {
+ text += `\n\n/status ${task_id}`;
+ }
+ } else {
+ const default_chat_id =
+ this.db.get_setting("telegram_default_chat_id", "") ?? "";
+ if (!default_chat_id) {
+ console.log(
+ `[Telegram] No origin and no telegram_default_chat_id configured for task #${task_id}, skipping`,
+ );
+ return;
+ }
+ // ≙ str(default_chat_id).lstrip("-").isdigit()
+ chat_id = /^\d+$/.test(String(default_chat_id).replace(/^-+/, ""))
+ ? parseInt(String(default_chat_id), 10)
+ : default_chat_id;
+ const status_emoji = is_completed ? "✅" : "❌";
+ text = `${status_emoji} Task #${task_id}: ${title}\n${body}`;
+ if (!is_completed) {
+ text += `\n\n/status ${task_id}`;
+ }
+ console.log(
+ `[Telegram] Using default chat_id=${chat_id} for task #${task_id}`,
+ );
+ }
+
+ // Free origin memory after terminal state (Python pops right after
+ // scheduling the coroutine; here we pop before awaiting the sends).
+ this._task_origin.delete(task_id);
+
+ // ≙ the _send_and_track() coroutine
+ try {
+ const react_target = reaction_message_id ?? orig_message_id;
+ if (react_target) {
+ // Add emoji reaction to the message that triggered the task (or resume)
+ const emoji =
+ msg.type === OutboundMessageType.TASK_COMPLETED ? "👍" : "👎";
+ try {
+ await api("setMessageReaction", {
+ chat_id,
+ message_id: react_target,
+ reaction: [{ type: "emoji", emoji }],
+ });
+ } catch (e) {
+ console.log(
+ `[Telegram] Failed to set reaction on message ${react_target}: ${e}`,
+ );
+ }
+ }
+
+ const params: Record = { chat_id, text };
+ if (orig_message_id !== null)
+ params["reply_to_message_id"] = orig_message_id;
+ const sent = (await api("sendMessage", params)) as
+ | { message_id: number }
+ | null
+ | undefined;
+ if (sent) {
+ this._notification_map.set(sent.message_id, task_id);
+ console.log(
+ `[Telegram] Notification msg_id=${sent.message_id} mapped to task #${task_id}`,
+ );
+ }
+ } catch (e) {
+ console.log(`[Telegram] Failed to send notification to ${chat_id}: ${e}`);
+ }
+ }
+
+ // ── private helpers ──────────────────────────────────────────
+
+ /** Entry point for the polling loop (≙ the bot thread's _run_bot). */
+ async _run_bot(): Promise {
+ this._ready = true; // ≙ self._loop_ready.set()
+ try {
+ await this._start_app();
+ } catch (e) {
+ console.log(`[Telegram] Bot error: ${e}`);
+ }
+ }
+
+ /** ≙ Application bootstrap + updater.start_polling(drop_pending_updates=True). */
+ async _start_app(): Promise {
+ await this._drop_pending_updates();
+ console.log("[Telegram] Bot polling started");
+
+ while (this._running) {
+ await this._poll_once();
+ }
+ }
+
+ /** Skip the pending-update backlog (≙ drop_pending_updates=True). */
+ async _drop_pending_updates(): Promise {
+ if (!this._api) return;
+ try {
+ const updates = (await this._api("getUpdates", {
+ offset: -1,
+ timeout: 0,
+ })) as TgUpdate[] | null | undefined;
+ if (updates && updates.length > 0) {
+ const last = updates[updates.length - 1]!;
+ this._offset = (last.update_id ?? 0) + 1;
+ }
+ } catch (e) {
+ console.log(`[Telegram] Failed to drop pending updates: ${e}`);
+ }
+ }
+
+ /** One getUpdates long-poll iteration; routes each update to the handlers. */
+ async _poll_once(): Promise {
+ if (!this._api) return;
+ try {
+ const updates = (await this._api("getUpdates", {
+ offset: this._offset,
+ timeout: 30,
+ allowed_updates: ["message"],
+ })) as TgUpdate[] | null | undefined;
+ for (const update of updates ?? []) {
+ if (typeof update.update_id === "number") {
+ this._offset = update.update_id + 1;
+ }
+ try {
+ await this._handle_update(update);
+ } catch (e) {
+ console.log(`[Telegram] Error handling update: ${e}`);
+ }
+ }
+ } catch (e) {
+ console.log(`[Telegram] Polling error: ${e}`);
+ await sleep(1000);
+ }
+ }
+
+ /**
+ * Route a Bot API update: registered slash commands go to their _cmd_*
+ * handler, all other text goes to _handle_text_message (which itself deals
+ * with /dir and /agent — same effective routing as the Python handlers).
+ */
+ async _handle_update(update: TgUpdate): Promise {
+ const msg = update.message;
+ if (!msg || typeof msg.text !== "string" || !msg.text) return;
+ const text = msg.text.trim();
+ if (text.startsWith("/")) {
+ const parts = text.split(/\s+/);
+ const cmd = parts[0]!.slice(1).split("@")[0]!.toLowerCase();
+ const handler = this._command_handlers[cmd];
+ if (handler) {
+ await handler(update, { args: parts.slice(1) });
+ return;
+ }
+ }
+ await this._handle_text_message(update, { args: [] });
+ }
+
+ /** ≙ update.message.reply_text(...) */
+ async _reply_text(
+ update: TgUpdate,
+ text: string,
+ extra: Record = {},
+ ): Promise {
+ await this._api!("sendMessage", {
+ chat_id: update.message!.chat.id,
+ text,
+ ...extra,
+ });
+ }
+
+ async _send_text(chat_id: number | string, text: string): Promise {
+ try {
+ await this._api!("sendMessage", { chat_id, text });
+ } catch (e) {
+ console.log(`[Telegram] Failed to send message to ${chat_id}: ${e}`);
+ }
+ }
+
+ _is_allowed(user_id: number): boolean {
+ if (this._allowed_users.size === 0) return true;
+ return this._allowed_users.has(user_id);
+ }
+
+ // ── unified text message handler ──────────────────────────────
+
+ /**
+ * 格式化转发的消息文本,添加发送者和时间信息
+ *
+ * @param text 消息文本
+ * @param update Telegram Update 对象
+ * @returns 格式化后的文本
+ */
+ _format_forwarded_text(text: string, update: TgUpdate): string {
+ const msg = update.message!;
+ const is_forwarded = msg.forward_from || msg.forward_date;
+
+ if (!is_forwarded) return text;
+
+ const parts: string[] = ["📨 [转发消息]"];
+
+ // 获取发送者信息
+ let sender_name = "未知用户";
+ if (msg.forward_from) {
+ const sender = msg.forward_from;
+ if (sender.username) {
+ sender_name = `@${sender.username}`;
+ } else {
+ const name_parts: string[] = [sender.first_name || ""];
+ if (sender.last_name) {
+ name_parts.push(sender.last_name);
+ }
+ sender_name = name_parts.filter(Boolean).join(" ");
+ }
+ parts.push(`转发自: ${sender_name}`);
+ } else if (msg.forward_from_chat) {
+ const chat = msg.forward_from_chat;
+ sender_name = chat.title || chat.username || "未知频道";
+ if (chat.type === "channel") {
+ parts.push(`转发自频道: ${sender_name}`);
+ } else if (chat.type === "group" || chat.type === "supergroup") {
+ parts.push(`转发自群组: ${sender_name}`);
+ } else {
+ parts.push(`转发自: ${sender_name}`);
+ }
+ } else {
+ parts.push(`转发自: ${sender_name}`);
+ }
+
+ // 添加时间戳 (UTC+8, ≙ datetime.fromtimestamp(..., tz=timezone(timedelta(hours=8))))
+ if (msg.forward_date) {
+ const d = new Date((msg.forward_date + 8 * 3600) * 1000);
+ const pad = (n: number) => String(n).padStart(2, "0");
+ const ts =
+ `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ` +
+ `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`;
+ parts.push(`时间: ${ts}`);
+ }
+
+ parts.push("\n--- 转发内容 ---");
+ parts.push(text);
+
+ return parts.join("\n");
+ }
+
+ /** Handle any non-command text: resume-by-reply or create task. */
+ async _handle_text_message(
+ update: TgUpdate,
+ _context: TgContext,
+ ): Promise {
+ if (!this._is_allowed(update.message!.from!.id)) {
+ await this._reply_text(
+ update,
+ "⛔ You are not authorised to use this bot.",
+ );
+ return;
+ }
+
+ let text = (update.message!.text || "").trim();
+ if (!text) return;
+
+ // ── /dir command: switch working directory ─────────────────
+ const dir_reply = handle_dir_command(text, "telegram", this.db);
+ if (dir_reply !== null) {
+ await this._reply_text(update, dir_reply);
+ return;
+ }
+
+ // ── /agent command: switch coding agent ──────────────────
+ const agent_reply = handle_agent_command(text, "telegram", this.db);
+ if (agent_reply !== null) {
+ await this._reply_text(update, agent_reply);
+ return;
+ }
+
+ // ── 检测转发消息 ───────────────────────────────────────
+ text = this._format_forwarded_text(text, update);
+
+ const chat_id = update.message!.chat.id;
+
+ // ── reply to a notification → resume task ─────────────────
+ const reply = update.message!.reply_to_message;
+ if (reply) {
+ const task_id = this._notification_map.get(reply.message_id);
+ if (task_id) {
+ const task = this.db.get_task(task_id);
+ if (task && task["session_id"]) {
+ this.db.update_task(task_id, {
+ status: "pending",
+ prompt: text,
+ result: null,
+ error: null,
+ question: null,
+ });
+ this._task_origin.set(task_id, [
+ chat_id,
+ update.message!.message_id,
+ update.message!.message_id,
+ ]);
+
+ // Add "eyes" reaction and send resuming message
+ try {
+ await this._api!("setMessageReaction", {
+ chat_id,
+ message_id: update.message!.message_id,
+ reaction: [{ type: "emoji", emoji: "👀" }],
+ });
+ } catch (e) {
+ console.log(`[Telegram] Failed to set resume reaction: ${e}`);
+ }
+ await this._reply_text(update, "▶️");
+ console.log(`[Telegram] Auto-resuming task ${task_id} from reply`);
+ return;
+ } else {
+ await this._reply_text(
+ update,
+ `❌ Task #${task_id} has no saved session to resume.`,
+ );
+ return;
+ }
+ }
+ }
+
+ // ── default: create a new task ────────────────────────────
+ await this._create_task(text, chat_id, update);
+ }
+
+ /** Create a new task from any message text. */
+ async _create_task(
+ text: string,
+ chat_id: number | string,
+ update: TgUpdate,
+ ): Promise {
+ const msg = update.message!;
+
+ // 检查是否为转发消息,用于添加标题标记
+ const is_forwarded = Boolean(msg.forward_from || msg.forward_date);
+ const title_prefix = is_forwarded ? "📨 " : "";
+ const title = text.slice(0, 60) + (text.length > 60 ? "…" : "");
+
+ const working_dir = await resolve_working_dir(text, "telegram", this.db);
+
+ const task = makeTask({
+ title: `[Telegram] ${title_prefix}${title}`,
+ prompt: text,
+ working_dir,
+ schedule_type: ScheduleType.IMMEDIATE,
+ tags: "telegram" + (is_forwarded ? ", forwarded" : ""),
+ agent: resolve_agent("telegram", this.db),
+ });
+ const task_id = this.scheduler.submit_task(task);
+ console.log(
+ `[Telegram] Task #${task_id} created from message${is_forwarded ? " (forwarded)" : ""}`,
+ );
+
+ const message_id = msg.message_id;
+ this._task_origin.set(task_id, [chat_id, message_id, message_id]);
+
+ // Acknowledge with an "eyes" reaction and a brief running hint
+ // (≙ the _react() coroutine scheduled via run_coroutine_threadsafe)
+ try {
+ await this._api!("setMessageReaction", {
+ chat_id,
+ message_id,
+ reaction: [{ type: "emoji", emoji: "👀" }],
+ });
+ await this._api!("sendMessage", {
+ chat_id,
+ text: `Task #${task_id} is running…`,
+ reply_to_message_id: message_id,
+ });
+ } catch (e) {
+ console.log(`[Telegram] Failed to set reaction: ${e}`);
+ }
+ }
+
+ // ── command handlers ──────────────────────────────────────────
+
+ async _cmd_help(update: TgUpdate, _context: TgContext): Promise {
+ if (!this._is_allowed(update.message!.from!.id)) {
+ await this._reply_text(
+ update,
+ "⛔ You are not authorised to use this bot.",
+ );
+ return;
+ }
+ await this._reply_text(update, HELP_TEXT, { parse_mode: "MarkdownV2" });
+ }
+
+ async _cmd_status(update: TgUpdate, context: TgContext): Promise {
+ if (!this._is_allowed(update.message!.from!.id)) {
+ await this._reply_text(update, "⛔ Not authorised.");
+ return;
+ }
+
+ const arg0 =
+ context.args.length > 0 ? context.args[0]!.replace(/^#+/, "") : "";
+ if (!/^\d+$/.test(arg0)) {
+ await this._reply_text(update, "Usage: /status ");
+ return;
+ }
+
+ const task_id = parseInt(arg0, 10);
+ const task = this.db.get_task(task_id);
+ if (!task) {
+ await this._reply_text(update, `❌ Task #${task_id} not found.`);
+ return;
+ }
+
+ const status_icon: Record = {
+ pending: "🕐",
+ scheduled: "📅",
+ running: "⏳",
+ completed: "✅",
+ failed: "❌",
+ cancelled: "🚫",
+ };
+ const icon = status_icon[task["status"] as string] ?? "•";
+ const lines = [
+ `${icon} Task #${task_id} — ${task["status"]}`,
+ `${task["title"]}`,
+ `Created: ${String(task["created_at"] ?? "—").slice(0, 16)}`,
+ `Last run: ${String(task["last_run_at"] || "—").slice(0, 16)}`,
+ ];
+ if (task["error"]) {
+ lines.push(`\nError: ${String(task["error"]).slice(0, 300)}`);
+ }
+ if (task["result"]) {
+ lines.push(`\nResult: ${String(task["result"]).slice(0, 500)}`);
+ }
+
+ await this._reply_text(update, lines.join("\n"));
+ }
+
+ async _cmd_cancel(update: TgUpdate, context: TgContext): Promise {
+ if (!this._is_allowed(update.message!.from!.id)) {
+ await this._reply_text(update, "⛔ Not authorised.");
+ return;
+ }
+
+ const arg0 =
+ context.args.length > 0 ? context.args[0]!.replace(/^#+/, "") : "";
+ if (!/^\d+$/.test(arg0)) {
+ await this._reply_text(update, "Usage: /cancel ");
+ return;
+ }
+
+ const task_id = parseInt(arg0, 10);
+ const task = this.db.get_task(task_id);
+ if (!task) {
+ await this._reply_text(update, `❌ Task #${task_id} not found.`);
+ return;
+ }
+ const status = task["status"] as string;
+ if (
+ status === "completed" ||
+ status === "failed" ||
+ status === "cancelled"
+ ) {
+ await this._reply_text(
+ update,
+ `ℹ️ Task #${task_id} is already ${status}.`,
+ );
+ return;
+ }
+
+ this.db.update_task(task_id, { status: "cancelled" });
+ await this._reply_text(update, `🚫 Task #${task_id} cancelled.`);
+ }
+
+ async _cmd_resume(update: TgUpdate, context: TgContext): Promise {
+ if (!this._is_allowed(update.message!.from!.id)) {
+ await this._reply_text(update, "⛔ Not authorised.");
+ return;
+ }
+
+ const arg0 =
+ context.args.length > 0 ? context.args[0]!.replace(/^#+/, "") : "";
+ if (!/^\d+$/.test(arg0)) {
+ await this._reply_text(update, "Usage: /resume ");
+ return;
+ }
+
+ const tid = parseInt(arg0, 10);
+ const resume_msg = context.args.slice(1).join(" ").trim();
+ if (!resume_msg) {
+ await this._reply_text(
+ update,
+ "Please provide a message to resume with.",
+ );
+ return;
+ }
+
+ const task = this.db.get_task(tid);
+ if (!task || !task["session_id"]) {
+ await this._reply_text(
+ update,
+ `❌ Task #${tid} not found or has no saved session.`,
+ );
+ return;
+ }
+
+ this.db.update_task(tid, {
+ status: "pending",
+ prompt: resume_msg,
+ result: null,
+ error: null,
+ question: null,
+ });
+ const chat_id = update.message!.chat.id;
+ this._task_origin.set(tid, [
+ chat_id,
+ update.message!.message_id,
+ update.message!.message_id,
+ ]);
+
+ // Add "eyes" reaction to the user's command message
+ try {
+ await this._api!("setMessageReaction", {
+ chat_id,
+ message_id: update.message!.message_id,
+ reaction: [{ type: "emoji", emoji: "👀" }],
+ });
+ } catch (e) {
+ console.log(`[Telegram] Failed to set resume reaction: ${e}`);
+ }
+
+ await this._reply_text(update, "▶️");
+ }
+}
+
+// ── helpers ──────────────────────────────────────────────────────────────────
+
+/** Escape special MarkdownV2 characters. */
+export function _escape_md(text: string): string {
+ const special = "\\_*[]()~`>#+-=|{}.!";
+ return [...text].map((c) => (special.includes(c) ? `\\${c}` : c)).join("");
+}
+
+// ── factory helper ───────────────────────────────────────────────────────────
+
+/** Create a TelegramChannel from explicit params or environment variables. */
+export function create_telegram_channel(
+ db: TelegramDB,
+ scheduler: TelegramScheduler,
+ bus: MessageBus | null = null,
+ token: string = "",
+ allowed_users_str: string = "",
+): TelegramChannel | null {
+ token = (token || process.env.TELEGRAM_BOT_TOKEN || "").trim();
+ if (!token) return null;
+
+ const allowed_raw = (
+ allowed_users_str ||
+ process.env.TELEGRAM_ALLOWED_USERS ||
+ ""
+ ).trim();
+ const allowed_users: number[] = [];
+ if (allowed_raw) {
+ for (const raw of allowed_raw.split(",")) {
+ const uid = raw.trim();
+ if (/^\d+$/.test(uid)) {
+ allowed_users.push(parseInt(uid, 10));
+ }
+ }
+ }
+
+ return new TelegramChannel(
+ bus ?? new MessageBus(),
+ db,
+ scheduler,
+ token,
+ allowed_users,
+ );
+}
diff --git a/backend/src/channels/weixin.ts b/backend/src/channels/weixin.ts
new file mode 100644
index 0000000..483d8ef
--- /dev/null
+++ b/backend/src/channels/weixin.ts
@@ -0,0 +1,1157 @@
+/**
+ * Weixin channel for AgentForge — ported from channels/weixin_channel.py.
+ *
+ * Text-only MVP backed by a sidecar bridge process that communicates with the
+ * backend via newline-delimited JSON over stdio. The Python original spawned
+ * the Node bridge (`node weixin_bridge/index.mjs`); this port spawns the
+ * TypeScript bridge under Bun (`bun weixin_bridge/index.ts`). Protocol
+ * strings, NDJSON command/event shapes, and user-facing strings are kept
+ * byte-identical to the Python source.
+ *
+ * Porting notes
+ * ─────────────
+ * - subprocess.Popen → the injectable `_hooks.spawn_bridge` seam returning a
+ * WeixinBridgeProcess (stdin writer + stdout line iterable + poll/terminate/
+ * wait). Tests inject a fake process exactly where the pytest suite
+ * monkeypatched `channels.weixin_channel.subprocess.Popen`.
+ * - The stdout reader daemon thread → an async loop (`_read_bridge_events`)
+ * whose promise is kept in `_reader_promise` (≙ `_reader_thread`).
+ * - Python merged the bridge's stderr into stdout (stderr=subprocess.STDOUT);
+ * here the default spawn inherits stderr so bridge logs go straight to the
+ * backend's stderr instead of through the "Ignoring non-JSON" logger.
+ * - threading.Lock fields are dropped (single-threaded event loop).
+ * - resolve_working_dir is async in TS, so _handle_message_event (and thus
+ * _handle_bridge_event) is async.
+ */
+
+import crypto from "node:crypto";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+
+import {
+ Channel,
+ MessageBus,
+ OutboundMessageType,
+ type OutboundMessage,
+ type TaskDBLike,
+} from "../bus.ts";
+import {
+ makeTask,
+ ScheduleType,
+ type PromptImage,
+ type Task,
+} from "../types.ts";
+import {
+ handle_agent_command,
+ resolve_agent,
+ type SettingsDB,
+} from "./agent_utils.ts";
+import { handle_dir_command, resolve_working_dir } from "./dir_utils.ts";
+
+export const WEIXIN_UPLOADABLE_IMAGE_SUFFIXES = new Set([
+ ".png",
+ ".jpg",
+ ".jpeg",
+ ".gif",
+ ".webp",
+]);
+const WEIXIN_MARKDOWN_IMAGE_RE = /!\[[^\]]*]\(([^)]+)\)/g;
+// ≙ re.compile(r"^/new(?:\s+(.*))?$", re.IGNORECASE | re.DOTALL)
+const WEIXIN_NEW_SESSION_RE = /^\/new(?:\s+([\s\S]*))?$/i;
+
+// ── injectable process seam ───────────────────────────────────────
+
+/** Writable stdin of the bridge process (≙ Popen.stdin in text mode). */
+export interface WeixinBridgeStdin {
+ write(data: string): unknown;
+ flush?(): unknown;
+}
+
+/**
+ * Minimal view of the spawned bridge process (≙ subprocess.Popen). `stdout`
+ * yields lines (like iterating Popen.stdout in text mode); tests supply a
+ * plain string[].
+ */
+export interface WeixinBridgeProcess {
+ stdin: WeixinBridgeStdin | null;
+ stdout: AsyncIterable | Iterable | null;
+ /** ≙ Popen.poll(): null while alive, exit code once exited. */
+ poll(): number | null;
+ terminate(): void;
+ /** ≙ Popen.wait(timeout=...); may return a promise (not awaited). */
+ wait(timeout?: number | null): unknown;
+}
+
+export type SpawnBridge = (
+ cmd: string[],
+ env: Record,
+) => WeixinBridgeProcess;
+
+/** Split a byte stream into lines (trailing newline included, like Python). */
+async function* _iter_lines(
+ stream: ReadableStream,
+): AsyncGenerator {
+ const decoder = new TextDecoder();
+ let buffered = "";
+ for await (const chunk of stream) {
+ buffered += decoder.decode(chunk, { stream: true });
+ let idx: number;
+ while ((idx = buffered.indexOf("\n")) !== -1) {
+ yield buffered.slice(0, idx + 1);
+ buffered = buffered.slice(idx + 1);
+ }
+ }
+ buffered += decoder.decode();
+ if (buffered) yield buffered;
+}
+
+/**
+ * Default spawn implementation (≙ subprocess.Popen(..., text=True)).
+ * Bun.spawn throws synchronously with code "ENOENT" when the executable is
+ * missing, matching Python's FileNotFoundError handling in start().
+ */
+function _default_spawn_bridge(
+ cmd: string[],
+ env: Record,
+): WeixinBridgeProcess {
+ const proc = Bun.spawn({
+ cmd: cmd as [string, ...string[]],
+ env,
+ stdin: "pipe",
+ stdout: "pipe",
+ // Python used stderr=subprocess.STDOUT; inheriting keeps bridge logs
+ // visible without routing them through the NDJSON event reader.
+ stderr: "inherit",
+ });
+ return {
+ stdin: proc.stdin,
+ stdout: _iter_lines(proc.stdout),
+ poll: () => proc.exitCode,
+ terminate: () => {
+ proc.kill();
+ },
+ wait: (_timeout?: number | null) => proc.exited,
+ };
+}
+
+/** ≙ shutil.which — the backend itself runs under Bun, so the current
+ * executable is the most reliable full path to the runtime (it survives the
+ * minimal PATH a Finder/Dock-launched macOS app inherits). */
+function _default_which(cmd: string): string | null {
+ const execPath = process.execPath || "";
+ if (path.basename(execPath) === cmd) {
+ return execPath;
+ }
+
+ for (const dir of (process.env.PATH || "").split(path.delimiter)) {
+ if (!dir) continue;
+ const candidate = path.join(dir, cmd);
+ if (fs.existsSync(candidate)) {
+ return candidate;
+ }
+ }
+ return null;
+}
+
+/**
+ * Locate the Bun binary used to run the sidecar bridge.
+ *
+ * ≙ Python's _find_node_executable (the bridge ran on Node there): macOS apps
+ * launched from Finder/Dock inherit a minimal PATH that excludes Homebrew
+ * (`/opt/homebrew/bin`), so fall back to the common install locations when
+ * the primary lookup misses.
+ */
+export function _find_bun_executable(): string | null {
+ const found = _hooks.which("bun");
+ if (found) {
+ return found;
+ }
+ for (const candidate of [
+ "/opt/homebrew/bin/bun",
+ "/usr/local/bin/bun",
+ path.join(os.homedir(), ".bun", "bin", "bun"),
+ ]) {
+ if (_hooks.path_exists(candidate)) {
+ return candidate;
+ }
+ }
+ return null;
+}
+
+function _is_bridge_script(entrypoint: string): boolean {
+ return new Set([".ts", ".js", ".mjs", ".cjs"]).has(
+ path.extname(entrypoint).toLowerCase(),
+ );
+}
+
+// Test seams (≙ the pytest monkeypatch targets):
+// spawn_bridge ≙ channels.weixin_channel.subprocess.Popen
+// which ≙ channels.weixin_channel.shutil.which
+// path_exists ≙ channels.weixin_channel.os.path.exists
+// handle_dir_command ≙ channels.dir_utils.handle_dir_command (Python
+// handle_agent_command imports these inside _handle_message_event, so
+// module-attribute patches took effect there)
+export const _hooks = {
+ spawn_bridge: _default_spawn_bridge as SpawnBridge,
+ which: _default_which,
+ path_exists: (p: string): boolean => fs.existsSync(p),
+ handle_dir_command,
+ handle_agent_command,
+};
+
+// ── structural dependency interfaces ──────────────────────────────
+
+/** Minimal structural view of TaskDB used by this channel. */
+export interface WeixinTaskDB extends TaskDBLike, SettingsDB {
+ update_task(task_id: number, updates: Record): void;
+ get_task_runs(task_id: number, limit?: number): unknown;
+ get_run_output_events(run_id: number, limit?: number): unknown;
+}
+
+/**
+ * Minimal structural view of TaskScheduler (do NOT import scheduler.ts).
+ * Python source: TaskScheduler.submit_task(self, task, depends_on=None) -> int;
+ * this channel only ever calls submit_task(task).
+ */
+export interface WeixinScheduler {
+ submit_task(task: Task): number;
+}
+
+/** Status snapshot consumed by _build_weixin_channel_status in taskboard. */
+interface WeixinStatus {
+ configured: boolean;
+ login_status: string;
+ qr_code_url: string;
+ last_error: string;
+ account_id: string;
+ user_id: string;
+}
+
+// ── helpers (≙ urllib.parse / pathlib bits) ───────────────────────
+
+/** ≙ urlparse(target).path for file:// references (scheme + netloc dropped). */
+function _file_url_path(target: string): string {
+ const rest = target.slice("file://".length);
+ const slash = rest.indexOf("/");
+ return slash >= 0 ? rest.slice(slash) : "";
+}
+
+/** ≙ urllib.parse.unquote (left untouched when percent-decoding fails). */
+function _unquote(value: string): string {
+ try {
+ return decodeURIComponent(value);
+ } catch {
+ return value;
+ }
+}
+
+/** ≙ Path.expanduser(). */
+function _expanduser(p: string): string {
+ if (p === "~") return os.homedir();
+ if (p.startsWith("~/")) return path.join(os.homedir(), p.slice(2));
+ return p;
+}
+
+function _is_plain_object(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+const PNG_HEADER = Buffer.from([
+ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
+]);
+const JPEG_HEADER = Buffer.from([0xff, 0xd8, 0xff]);
+
+// ── WeixinChannel ─────────────────────────────────────────────────
+
+/** Weixin integration using a sidecar bridge process. */
+export class WeixinChannel extends Channel {
+ declare db: WeixinTaskDB;
+ scheduler: WeixinScheduler;
+ bridge_cmd: string[];
+ _bridge_proc: WeixinBridgeProcess | null = null;
+ /** ≙ self._reader_thread (daemon thread joining the bridge stdout). */
+ _reader_promise: Promise | null = null;
+
+ // task_id -> origin metadata used for notifications and resume
+ _task_origin: Map> = new Map();
+
+ // account_id:peer_id -> current task_id. Weixin has no thread, so this
+ // gives each peer a current AgentForge session until /new starts another.
+ _peer_current_task: Map = new Map();
+
+ // notification message_id -> task_id for resume-by-reply
+ _notification_map: Map = new Map();
+
+ // request_id -> task_id for sent acknowledgements from the bridge
+ _pending_notifications: Map = new Map();
+
+ _status: WeixinStatus = {
+ configured: false,
+ login_status: "idle",
+ qr_code_url: "",
+ last_error: "",
+ account_id: "",
+ user_id: "",
+ };
+
+ constructor(
+ bus: MessageBus,
+ db: WeixinTaskDB,
+ scheduler: WeixinScheduler,
+ bridge_cmd: string[] | null = null,
+ ) {
+ super("weixin", bus, db);
+ this.scheduler = scheduler;
+ this.bridge_cmd = bridge_cmd ?? this._default_bridge_cmd();
+
+ bus.subscribe_outbound(this._on_outbound);
+ }
+
+ _bridge_script_path(): string {
+ // ≙ the PyInstaller sys._MEIPASS branch: the Python build shipped the
+ // bridge via --add-data and resolved it under sys._MEIPASS when frozen.
+ // The Bun single-binary build (`bun build --compile`) cannot resolve
+ // source-relative paths either, so the packaged app sets
+ // AGENTFORGE_WEIXIN_BRIDGE to the bridge entrypoint it ships alongside
+ // the binary; dev falls back to the source tree next to this module.
+ const override = process.env.AGENTFORGE_WEIXIN_BRIDGE;
+ if (override) {
+ return override;
+ }
+ const packagedBridge = path.join(
+ path.dirname(process.execPath),
+ "weixin-bridge",
+ );
+ if (_hooks.path_exists(packagedBridge)) {
+ return packagedBridge;
+ }
+ return path.join(import.meta.dir, "weixin_bridge", "index.ts");
+ }
+
+ _default_bridge_cmd(): string[] {
+ const bridgeEntrypoint = this._bridge_script_path();
+ if (!_is_bridge_script(bridgeEntrypoint)) {
+ return [bridgeEntrypoint];
+ }
+
+ // Resolve bun to a full path so it's found even under the minimal PATH
+ // a packaged macOS app inherits. Falls back to bare "bun" when missing;
+ // start() then surfaces the spawn ENOENT as an error status.
+ return [_find_bun_executable() || "bun", bridgeEntrypoint];
+ }
+
+ start(): void {
+ this._running = true;
+ try {
+ const env: Record = { ...process.env };
+ if (env["AGENTFORGE_WEIXIN_DATA_DIR"] === undefined) {
+ env["AGENTFORGE_WEIXIN_DATA_DIR"] = path.join(
+ os.homedir(),
+ ".agentforge",
+ "weixin",
+ );
+ }
+ if (env["AGENTFORGE_WEIXIN_BASE_URL"] === undefined) {
+ env["AGENTFORGE_WEIXIN_BASE_URL"] =
+ this.db.get_setting(
+ "weixin_base_url",
+ "https://ilinkai.weixin.qq.com",
+ ) ?? "https://ilinkai.weixin.qq.com";
+ }
+ if (env["AGENTFORGE_WEIXIN_ACCOUNT_ID"] === undefined) {
+ env["AGENTFORGE_WEIXIN_ACCOUNT_ID"] =
+ this.db.get_setting("weixin_account_id", "") ?? "";
+ }
+ this._bridge_proc = _hooks.spawn_bridge(this.bridge_cmd, env);
+ } catch (exc) {
+ this._running = false;
+ this._bridge_proc = null;
+ if ((exc as NodeJS.ErrnoException)?.code === "ENOENT") {
+ const msg =
+ "Bun not found. Install Bun (https://bun.sh) to use the Weixin channel.";
+ console.log(`[Weixin] ${msg}`);
+ this._update_status({ login_status: "error", last_error: msg });
+ return;
+ }
+ const msg = `Failed to start Weixin bridge: ${exc}`;
+ console.log(`[Weixin] ${msg}`);
+ this._update_status({ login_status: "error", last_error: msg });
+ return;
+ }
+
+ this._reader_promise = this._read_bridge_events().catch((exc) => {
+ console.log(`[Weixin] Bridge reader error: ${exc}`);
+ });
+ console.log("[Weixin] Bridge started");
+ }
+
+ stop(): void {
+ this._running = false;
+ this.bus.unsubscribe_outbound(this._on_outbound);
+ if (this._bridge_proc && this._bridge_proc.poll() === null) {
+ try {
+ this._bridge_proc.terminate();
+ void this._bridge_proc.wait(5);
+ } catch {
+ /* pass */
+ }
+ }
+ this._bridge_proc = null;
+ }
+
+ send(msg: OutboundMessage): void {
+ if (!this._running) {
+ return;
+ }
+ if (
+ msg.type !== OutboundMessageType.TASK_COMPLETED &&
+ msg.type !== OutboundMessageType.TASK_FAILED
+ ) {
+ return;
+ }
+
+ const task_id = msg.task_id;
+ const origin = this._task_origin.get(task_id);
+ if (!origin) {
+ console.log(
+ `[Weixin] No origin for task #${task_id}, skipping outbound notification`,
+ );
+ return;
+ }
+
+ const title =
+ (msg.payload["title"] as string | null | undefined) || `Task #${task_id}`;
+ const task = this.db.get_task(task_id) ?? {};
+ let text: string;
+ let image_paths: string[];
+ if (msg.type === OutboundMessageType.TASK_COMPLETED) {
+ let body =
+ ((msg.payload["result"] as string | null | undefined) || "").trim() ||
+ "Done.";
+ image_paths = this._collect_generated_image_paths(task_id, body, task);
+ if (image_paths.length > 0) {
+ body = this._hide_generated_image_paths(
+ body,
+ image_paths.length,
+ image_paths,
+ );
+ }
+ text = `✅ Task #${task_id} · ${title}\n${body}`;
+ } else {
+ const body = (
+ (msg.payload["error"] as string | null | undefined) || "Unknown error"
+ ).trim();
+ image_paths = [];
+ text = `❌ Task #${task_id} · ${title}\n${body}`;
+ }
+
+ const request_id = crypto.randomUUID().replaceAll("-", ""); // ≙ uuid.uuid4().hex
+ this._pending_notifications.set(request_id, task_id);
+ const command: Record = {
+ type: "send_message",
+ request_id,
+ account_id: origin["account_id"] ?? "",
+ peer_id: origin["peer_id"],
+ context_token: origin["context_token"] ?? "",
+ reply_to_message_id: origin["message_id"] ?? "",
+ text,
+ };
+ if (image_paths.length > 0) {
+ command["image_paths"] = image_paths;
+ }
+ this._send_command(command);
+
+ this._task_origin.delete(task_id);
+ }
+
+ /** Arrow-function property so subscribe/unsubscribe get a stable bound ref. */
+ _on_outbound = (msg: OutboundMessage): void => {
+ this.send(msg);
+ };
+
+ async _read_bridge_events(): Promise {
+ const proc = this._bridge_proc;
+ if (!proc || !proc.stdout) {
+ return;
+ }
+
+ for await (const raw_line of proc.stdout) {
+ if (!this._running) {
+ return;
+ }
+ const line = raw_line.trim();
+ if (!line) {
+ continue;
+ }
+ let event: Record;
+ try {
+ event = JSON.parse(line);
+ } catch {
+ console.log(`[Weixin] Ignoring non-JSON bridge output: ${line}`);
+ continue;
+ }
+ await this._handle_bridge_event(event);
+ }
+ }
+
+ async _handle_bridge_event(event: Record): Promise {
+ const event_type = event["type"];
+ if (event_type === "message") {
+ await this._handle_message_event(event);
+ } else if (event_type === "sent") {
+ this._handle_sent_event(event);
+ } else if (event_type === "qr") {
+ const qr_value =
+ ((event["qrcode_url"] as string | undefined) ?? "") || "";
+ console.log(
+ `[Weixin] QR payload len=${qr_value.length} prefix=${JSON.stringify(qr_value.slice(0, 80))}`,
+ );
+ this._update_status({
+ login_status: "waiting_for_scan",
+ qr_code_url: qr_value,
+ account_id: (event["account_id"] as string | undefined) ?? "",
+ last_error: "",
+ });
+ console.log("[Weixin] Bridge event: qr");
+ } else if (event_type === "scaned") {
+ this._update_status({ login_status: "scanned", last_error: "" });
+ console.log("[Weixin] Bridge event: scaned");
+ } else if (event_type === "login_success") {
+ this._update_status({
+ configured: true,
+ login_status: "connected",
+ qr_code_url: "",
+ account_id: (event["account_id"] as string | undefined) ?? "",
+ user_id: (event["user_id"] as string | undefined) ?? "",
+ last_error: "",
+ });
+ console.log("[Weixin] Bridge event: login_success");
+ } else if (event_type === "ready") {
+ this._update_status({
+ configured: true,
+ login_status: "connected",
+ qr_code_url: "",
+ account_id: (event["account_id"] as string | undefined) ?? "",
+ last_error: "",
+ });
+ console.log("[Weixin] Bridge event: ready");
+ } else if (event_type === "logged_out") {
+ this._update_status({
+ configured: false,
+ login_status: "idle",
+ qr_code_url: "",
+ last_error: "",
+ user_id: "",
+ });
+ console.log("[Weixin] Bridge event: logged_out");
+ } else if (event_type === "error") {
+ this._update_status({
+ login_status: "error",
+ last_error: (event["message"] as string | undefined) ?? "unknown_error",
+ });
+ console.log("[Weixin] Bridge event: error");
+ }
+ }
+
+ _handle_sent_event(event: Record): void {
+ const request_id =
+ ((event["request_id"] as string | undefined) || "") ?? "";
+ const message_id =
+ ((event["message_id"] as string | undefined) || "") ?? "";
+ const quoted_message_id =
+ ((event["quoted_message_id"] as string | undefined) || "") ?? "";
+ if (!request_id || (!message_id && !quoted_message_id)) {
+ return;
+ }
+ const task_id = this._pending_notifications.get(request_id);
+ if (task_id === undefined) {
+ return;
+ }
+ if (message_id) {
+ this._notification_map.set(message_id, task_id);
+ }
+ if (quoted_message_id) {
+ this._notification_map.set(quoted_message_id, task_id);
+ }
+ if (quoted_message_id) {
+ this._pending_notifications.delete(request_id);
+ }
+ }
+
+ async _handle_message_event(event: Record): Promise {
+ let text = ((event["text"] as string | null | undefined) || "").trim();
+ const image_paths = this._extract_image_paths(event);
+ if (!text && image_paths.length === 0) {
+ return;
+ }
+
+ const reply_to_message_id =
+ (event["reply_to_message_id"] as string | undefined) || "";
+ const reply_to_message_title =
+ (event["reply_to_message_title"] as string | undefined) || "";
+ const reply_to_message_text =
+ (event["reply_to_message_text"] as string | undefined) || "";
+ const peer_id =
+ (event["peer_id"] as string | undefined) ||
+ (event["from_user_id"] as string | undefined) ||
+ "";
+ const account_id = (event["account_id"] as string | undefined) || "";
+ const context_token = (event["context_token"] as string | undefined) || "";
+ const message_id = (event["message_id"] as string | undefined) || "";
+ const peer_key = this._peer_key(account_id, peer_id);
+
+ const new_match = text ? WEIXIN_NEW_SESSION_RE.exec(text) : null;
+ const force_new_session = Boolean(new_match);
+ if (new_match) {
+ text = (new_match[1] || "").trim();
+ this._clear_peer_current_task(peer_key);
+ if (!text && image_paths.length === 0) {
+ this._reply_to_event(
+ event,
+ "🆕 已开启新的 Weixin session,请发送新的任务内容。",
+ );
+ return;
+ }
+ }
+
+ const dir_reply = _hooks.handle_dir_command(text, "weixin", this.db);
+ if (dir_reply !== null) {
+ this._reply_to_event(event, dir_reply);
+ return;
+ }
+
+ const agent_reply = _hooks.handle_agent_command(text, "weixin", this.db);
+ if (agent_reply !== null) {
+ this._reply_to_event(event, agent_reply);
+ return;
+ }
+
+ let task_id: number | null = null;
+ if (reply_to_message_id) {
+ task_id = this._notification_map.get(reply_to_message_id) ?? null;
+ }
+
+ if (task_id === null) {
+ task_id = this._extract_task_id_from_reply_reference(
+ reply_to_message_title,
+ reply_to_message_text,
+ );
+ }
+
+ if (task_id === null && !force_new_session) {
+ task_id = this._get_peer_current_task(peer_key);
+ }
+
+ if (task_id !== null && !force_new_session) {
+ const task = this.db.get_task(task_id);
+ if (task && task["session_id"]) {
+ const updates = this._build_resume_updates(text, image_paths);
+ this.db.update_task(task_id, updates);
+ this._task_origin.set(task_id, {
+ account_id,
+ peer_id,
+ context_token,
+ message_id,
+ });
+ this._set_peer_current_task(peer_key, task_id);
+ this._reply_to_event(
+ event,
+ `▶️ 收到!正在唤醒 Task #${task_id},请稍候~`,
+ );
+ return;
+ }
+ if (
+ reply_to_message_id ||
+ reply_to_message_title ||
+ reply_to_message_text
+ ) {
+ this._reply_to_event(
+ event,
+ `❌ Task #${task_id} has no saved session to resume.`,
+ );
+ return;
+ }
+ }
+
+ const prompt = text || this._default_image_prompt(image_paths);
+ const prompt_images = this._build_prompt_images(image_paths);
+ const task = makeTask({
+ title: `[Weixin] ${prompt.slice(0, 60)}${prompt.length > 60 ? "…" : ""}`,
+ prompt,
+ working_dir: await resolve_working_dir(prompt, "weixin", this.db),
+ schedule_type: ScheduleType.IMMEDIATE,
+ tags: "weixin",
+ image_paths,
+ prompt_images,
+ agent: resolve_agent("weixin", this.db),
+ });
+ const new_task_id = this.scheduler.submit_task(task);
+ this._task_origin.set(new_task_id, {
+ account_id,
+ peer_id,
+ context_token,
+ message_id,
+ });
+ this._set_peer_current_task(peer_key, new_task_id);
+ this._reply_to_event(event, `Task #${new_task_id} is running…`);
+ }
+
+ _peer_key(account_id: string, peer_id: string): string {
+ return `${account_id}:${peer_id}`;
+ }
+
+ _get_peer_current_task(peer_key: string): number | null {
+ return this._peer_current_task.get(peer_key) ?? null;
+ }
+
+ _set_peer_current_task(peer_key: string, task_id: number): void {
+ if (!peer_key) {
+ return;
+ }
+ this._peer_current_task.set(peer_key, task_id);
+ }
+
+ _clear_peer_current_task(peer_key: string): void {
+ this._peer_current_task.delete(peer_key);
+ }
+
+ _default_image_prompt(image_paths: string[]): string {
+ if (image_paths.length === 1) {
+ return "请分析这张图片。";
+ }
+ return `请分析这 ${image_paths.length} 张图片。`;
+ }
+
+ _extract_image_paths(event: Record): string[] {
+ const paths: string[] = [];
+ const raw_paths = event["image_paths"];
+ if (Array.isArray(raw_paths)) {
+ for (const p of raw_paths) {
+ if (p) paths.push(String(p));
+ }
+ }
+ const raw_images = event["images"];
+ if (Array.isArray(raw_images)) {
+ for (const image of raw_images) {
+ if (!_is_plain_object(image)) {
+ continue;
+ }
+ const p = image["path"] || image["local_path"];
+ if (p) {
+ paths.push(String(p));
+ }
+ }
+ }
+ return this._dedupe_image_paths(paths);
+ }
+
+ _build_resume_updates(
+ prompt: string,
+ image_paths: string[],
+ ): Record {
+ const resume_prompt = prompt || this._default_image_prompt(image_paths);
+ const updates: Record = {
+ status: "pending",
+ prompt: resume_prompt,
+ result: null,
+ error: null,
+ question: null,
+ };
+ if (image_paths.length > 0) {
+ updates["image_paths"] = JSON.stringify(image_paths);
+ updates["prompt_images"] = JSON.stringify(
+ this._build_prompt_images(image_paths),
+ );
+ }
+ return updates;
+ }
+
+ _build_prompt_images(image_paths: string[]): PromptImage[] {
+ const prompt_images: PromptImage[] = [];
+ for (const image_path of image_paths) {
+ let data: string;
+ try {
+ data = fs.readFileSync(image_path).toString("base64");
+ } catch (exc) {
+ console.log(
+ `[Weixin] Failed to read inbound image ${image_path}: ${exc}`,
+ );
+ continue;
+ }
+ prompt_images.push({
+ name: path.basename(image_path),
+ media_type: this._image_media_type(image_path),
+ data,
+ });
+ }
+ return prompt_images;
+ }
+
+ _image_media_type(image_path: string): string {
+ const suffix = path.extname(image_path).toLowerCase();
+ if (suffix === ".png") {
+ return "image/png";
+ }
+ if (suffix === ".jpg" || suffix === ".jpeg") {
+ return "image/jpeg";
+ }
+ if (suffix === ".gif") {
+ return "image/gif";
+ }
+ if (suffix === ".webp") {
+ return "image/webp";
+ }
+ let header: Buffer;
+ try {
+ const fd = fs.openSync(image_path, "r");
+ try {
+ const buf = Buffer.alloc(12);
+ const read = fs.readSync(fd, buf, 0, 12, 0);
+ header = buf.subarray(0, read);
+ } finally {
+ fs.closeSync(fd);
+ }
+ } catch {
+ return "image/jpeg";
+ }
+ if (header.subarray(0, 8).equals(PNG_HEADER)) {
+ return "image/png";
+ }
+ if (header.subarray(0, 3).equals(JPEG_HEADER)) {
+ return "image/jpeg";
+ }
+ const gif_magic = header.subarray(0, 6).toString("latin1");
+ if (gif_magic === "GIF87a" || gif_magic === "GIF89a") {
+ return "image/gif";
+ }
+ if (
+ header.subarray(0, 4).toString("latin1") === "RIFF" &&
+ header.includes("WEBP")
+ ) {
+ return "image/webp";
+ }
+ return "image/jpeg";
+ }
+
+ _collect_generated_image_paths(
+ task_id: number,
+ content: string,
+ task: Record | null = null,
+ ): string[] {
+ const paths = this._generated_image_paths_for_task(task_id);
+ paths.push(
+ ...this._generated_image_paths_from_markdown(
+ content,
+ ((task ?? {})["working_dir"] as string | null | undefined) ?? null,
+ ),
+ );
+ return this._dedupe_image_paths(paths);
+ }
+
+ _generated_image_paths_for_task(task_id: number): string[] {
+ let runs: unknown;
+ try {
+ runs = this.db.get_task_runs(task_id, 1);
+ } catch (exc) {
+ console.log(`[Weixin] Failed to load runs for generated images: ${exc}`);
+ return [];
+ }
+ if (!Array.isArray(runs) || runs.length === 0) {
+ return [];
+ }
+
+ const first: unknown = runs[0];
+ const run_id = _is_plain_object(first) ? first["id"] : null;
+ if (!run_id) {
+ return [];
+ }
+ let events: unknown;
+ try {
+ events = this.db.get_run_output_events(run_id as number, 1000);
+ } catch (exc) {
+ console.log(
+ `[Weixin] Failed to load output events for generated images: ${exc}`,
+ );
+ return [];
+ }
+ if (!Array.isArray(events)) {
+ return [];
+ }
+
+ const paths: string[] = [];
+ for (const event of events) {
+ if (
+ !_is_plain_object(event) ||
+ event["event_type"] !== "generated_image"
+ ) {
+ continue;
+ }
+ let payload: unknown;
+ try {
+ payload = JSON.parse((event["content"] as string | undefined) || "{}");
+ } catch {
+ continue;
+ }
+ const p = _is_plain_object(payload) ? payload["path"] : null;
+ if (p) {
+ paths.push(p as string);
+ }
+ }
+ return paths;
+ }
+
+ _generated_image_paths_from_markdown(
+ content: string,
+ working_dir: string | null = null,
+ ): string[] {
+ const paths: string[] = [];
+ for (const match of (content || "").matchAll(WEIXIN_MARKDOWN_IMAGE_RE)) {
+ const image_path = this._local_image_path_from_reference(
+ match[1]!,
+ working_dir,
+ );
+ if (image_path) {
+ paths.push(image_path);
+ }
+ }
+ return paths;
+ }
+
+ _local_image_path_from_reference(
+ reference: string,
+ working_dir: string | null = null,
+ ): string | null {
+ let target = this._markdown_image_reference_target(reference);
+ if (
+ !target ||
+ target.startsWith("http://") ||
+ target.startsWith("https://") ||
+ target.startsWith("data:")
+ ) {
+ return null;
+ }
+ if (target.startsWith("file://")) {
+ target = _file_url_path(target);
+ } else if (target.startsWith("sandbox:")) {
+ target = target.slice("sandbox:".length);
+ }
+ target = _unquote(target).trim();
+ if (!target) {
+ return null;
+ }
+
+ let p = _expanduser(target);
+ if (!path.isAbsolute(p) && working_dir) {
+ p = path.join(_expanduser(working_dir), p);
+ }
+ return this._canonical_image_path(p);
+ }
+
+ _markdown_image_reference_target(reference: string): string {
+ const raw = (reference || "").trim();
+ if (!raw) {
+ return "";
+ }
+ if (raw.startsWith("<")) {
+ const end = raw.indexOf(">");
+ if (end >= 0) {
+ return raw.slice(1, end).trim();
+ }
+ }
+ if (raw[0] === "'" || raw[0] === '"') {
+ const end = raw.indexOf(raw[0]!, 1);
+ if (end > 0) {
+ return raw.slice(1, end).trim();
+ }
+ }
+ const titled = /^(.+?)\s+['"][^'"]*['"]\s*$/.exec(raw);
+ return (titled ? titled[1]! : raw).trim();
+ }
+
+ _dedupe_image_paths(image_paths: string[]): string[] {
+ const deduped: string[] = [];
+ const seen = new Set();
+ for (const image_path of image_paths) {
+ const canonical = this._canonical_image_path(image_path);
+ if (!canonical || seen.has(canonical)) {
+ continue;
+ }
+ seen.add(canonical);
+ deduped.push(canonical);
+ }
+ return deduped;
+ }
+
+ _canonical_image_path(image_path: string): string | null {
+ try {
+ const p = _expanduser(image_path);
+ if (
+ !WEIXIN_UPLOADABLE_IMAGE_SUFFIXES.has(path.extname(p).toLowerCase())
+ ) {
+ return null;
+ }
+ const stat = fs.statSync(p, { throwIfNoEntry: false });
+ if (!stat || !stat.isFile()) {
+ return null;
+ }
+ return fs.realpathSync(path.resolve(p)); // ≙ Path.resolve()
+ } catch {
+ return null;
+ }
+ }
+
+ _hide_generated_image_paths(
+ content: string,
+ image_count: number,
+ uploaded_paths: string[] | null = null,
+ ): string {
+ const uploaded = new Set();
+ for (const p of uploaded_paths ?? []) {
+ const canonical = this._canonical_image_path(p);
+ if (canonical) {
+ uploaded.add(canonical);
+ }
+ }
+ const lines: string[] = [];
+ for (const line of (content || "").split(/\r\n|\r|\n/)) {
+ const stripped = line.trim();
+ if (!stripped) {
+ lines.push("");
+ continue;
+ }
+ if (this._line_is_uploaded_image_path(stripped, uploaded)) {
+ continue;
+ }
+ const cleaned_line = this._remove_uploaded_markdown_image_refs(
+ line,
+ uploaded,
+ );
+ const visible = cleaned_line.trim();
+ if (visible && visible !== "-" && visible !== "*" && visible !== "+") {
+ lines.push(cleaned_line.replace(/\s+$/, "")); // ≙ rstrip()
+ }
+ }
+ const cleaned = lines.join("\n").trim();
+ if (!cleaned || cleaned.startsWith("已生成")) {
+ return `已生成 ${image_count} 张图片。`;
+ }
+ return cleaned;
+ }
+
+ _line_is_uploaded_image_path(
+ stripped_line: string,
+ uploaded_paths: Set,
+ ): boolean {
+ if (!stripped_line.startsWith("- ")) {
+ return false;
+ }
+ const candidate = stripped_line.slice(2).trim();
+ const canonical = this._canonical_image_path(candidate);
+ if (canonical && uploaded_paths.has(canonical)) {
+ return true;
+ }
+ return stripped_line.includes("/.codex/generated_images/");
+ }
+
+ _remove_uploaded_markdown_image_refs(
+ line: string,
+ uploaded_paths: Set,
+ ): string {
+ if (uploaded_paths.size === 0) {
+ return line;
+ }
+
+ return line.replace(WEIXIN_MARKDOWN_IMAGE_RE, (match, ref: string) => {
+ const image_path = this._local_image_path_from_reference(ref);
+ const canonical = image_path
+ ? this._canonical_image_path(image_path)
+ : null;
+ return canonical !== null && uploaded_paths.has(canonical) ? "" : match;
+ });
+ }
+
+ _reply_to_event(event: Record, text: string): void {
+ const peer_id =
+ (event["peer_id"] as string | undefined) ||
+ (event["from_user_id"] as string | undefined);
+ if (!peer_id) {
+ return;
+ }
+ this._send_command({
+ type: "send_message",
+ account_id: (event["account_id"] as string | undefined) ?? "",
+ peer_id,
+ context_token: (event["context_token"] as string | undefined) ?? "",
+ reply_to_message_id: (event["message_id"] as string | undefined) ?? "",
+ text,
+ });
+ }
+
+ _extract_task_id_from_reply_reference(...parts: string[]): number | null {
+ for (const part of parts) {
+ if (!part) {
+ continue;
+ }
+ const match = /\bTask\s+#(\d+)\b/.exec(part);
+ if (match) {
+ return parseInt(match[1]!, 10);
+ }
+ }
+ return null;
+ }
+
+ _send_command(payload: Record): void {
+ const proc_alive = Boolean(
+ this._bridge_proc && this._bridge_proc.poll() === null,
+ );
+ const stdin_ok = Boolean(this._bridge_proc && this._bridge_proc.stdin);
+ console.log(
+ `[Weixin] _send_command: type=${payload["type"]} proc_alive=${proc_alive} stdin_ok=${stdin_ok}`,
+ );
+ if (!this._bridge_proc || !this._bridge_proc.stdin) {
+ console.log(
+ "[Weixin] _send_command: bridge not running, command dropped",
+ );
+ return;
+ }
+ this._bridge_proc.stdin.write(JSON.stringify(payload) + "\n");
+ this._bridge_proc.stdin.flush?.();
+ }
+
+ request_login(): void {
+ console.log("[Weixin] request_login: called");
+ this._update_status({
+ configured: false,
+ login_status: "idle",
+ qr_code_url: "",
+ last_error: "",
+ user_id: "",
+ });
+ this._send_command({ type: "login" });
+ }
+
+ request_logout(): void {
+ console.log("[Weixin] request_logout: called");
+ this._update_status({
+ configured: false,
+ login_status: "idle",
+ qr_code_url: "",
+ last_error: "",
+ user_id: "",
+ });
+ this._send_command({ type: "logout" });
+ }
+
+ _update_status(updates: Partial): void {
+ for (const [k, v] of Object.entries(updates)) {
+ if (v !== null && v !== undefined) {
+ (this._status as unknown as Record)[k] = v;
+ }
+ }
+ }
+
+ get_status_snapshot(): Record {
+ return { ...this._status };
+ }
+}
diff --git a/channels/weixin_bridge/index.mjs b/backend/src/channels/weixin_bridge/index.ts
similarity index 64%
rename from channels/weixin_bridge/index.mjs
rename to backend/src/channels/weixin_bridge/index.ts
index 31d1ddd..785305d 100644
--- a/channels/weixin_bridge/index.mjs
+++ b/backend/src/channels/weixin_bridge/index.ts
@@ -1,14 +1,26 @@
+// AgentForge Weixin bridge — TypeScript port of channels/weixin_bridge/index.mjs.
+//
+// Plain sidecar process (run with `bun index.ts`) that speaks the WeChat ilink
+// protocol and communicates with the backend channel over stdio NDJSON.
+// Runtime behavior, protocol strings, and NDJSON message shapes are kept
+// byte-identical to the original Node ESM source; only types were added.
+// Stdlib-only (node:crypto/fs/path/readline + global fetch) — no npm deps.
+
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import readline from "node:readline";
const DEFAULT_BASE_URL = "https://ilinkai.weixin.qq.com";
-const INITIAL_BASE_URL = process.env.AGENTFORGE_WEIXIN_BASE_URL || DEFAULT_BASE_URL;
+const INITIAL_BASE_URL =
+ process.env.AGENTFORGE_WEIXIN_BASE_URL || DEFAULT_BASE_URL;
const BOT_TYPE = process.env.AGENTFORGE_WEIXIN_BOT_TYPE || "3";
-const DATA_DIR = process.env.AGENTFORGE_WEIXIN_DATA_DIR || path.join(process.env.HOME || ".", ".agentforge", "weixin");
+const DATA_DIR =
+ process.env.AGENTFORGE_WEIXIN_DATA_DIR ||
+ path.join(process.env.HOME || ".", ".agentforge", "weixin");
const ACCOUNT_FILE = path.join(DATA_DIR, "account.json");
-const AUTO_LOGIN = (process.env.AGENTFORGE_WEIXIN_AUTO_LOGIN || "true") !== "false";
+const AUTO_LOGIN =
+ (process.env.AGENTFORGE_WEIXIN_AUTO_LOGIN || "true") !== "false";
const ACCOUNT_ID_OVERRIDE = process.env.AGENTFORGE_WEIXIN_ACCOUNT_ID || "";
const CHANNEL_VERSION = "agentforge-weixin-bridge/0.2.0";
const DEFAULT_CDN_BASE_URL = process.env.AGENTFORGE_WEIXIN_CDN_BASE_URL || "";
@@ -26,7 +38,7 @@ const MESSAGE_ITEM_TYPE = {
const UPLOAD_MEDIA_TYPE = {
IMAGE: 1,
};
-const IMAGE_MIME_BY_EXT = {
+const IMAGE_MIME_BY_EXT: Record = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
@@ -34,115 +46,186 @@ const IMAGE_MIME_BY_EXT = {
".webp": "image/webp",
};
+interface BridgeState {
+ accountId: string;
+ baseUrl: string;
+ token: string;
+ userId: string;
+ syncCursor: string;
+}
+
+/** Inbound NDJSON command from the channel process. */
+interface BridgeCommand {
+ type?: string;
+ request_id?: string;
+ account_id?: string;
+ peer_id?: string;
+ context_token?: string;
+ reply_to_message_id?: string;
+ text?: string;
+ image_paths?: unknown;
+}
+
+interface UploadedImage {
+ filekey: string;
+ downloadEncryptedQueryParam: string;
+ aeskey: string; // hex string
+ fileSize: number;
+ fileSizeCiphertext: number;
+}
+
let shuttingDown = false;
-let loginInFlight = null;
+let loginInFlight: Promise | null = null;
let pollerStarted = false;
-let pollTimer = null;
-let state = loadState();
-const pendingSentMessages = new Map();
-
-function emit(event) {
+let pollTimer: ReturnType | null = null;
+let state: BridgeState = loadState();
+const pendingSentMessages = new Map<
+ string,
+ { requestId: string; peerId: string }
+>();
+
+function emit(event: Record): void {
process.stdout.write(`${JSON.stringify(event)}\n`);
}
-function log(message) {
+function log(message: string): void {
process.stderr.write(`[WeixinBridge] ${message}\n`);
}
-function ensureDataDir() {
+function ensureDataDir(): void {
fs.mkdirSync(DATA_DIR, { recursive: true });
}
-function ensureMediaDir(kind) {
+function ensureMediaDir(kind: string): string {
const dir = path.join(DATA_DIR, "media", kind);
fs.mkdirSync(dir, { recursive: true });
return dir;
}
-function mediaFileName(prefix, ext) {
+function mediaFileName(prefix: string, ext: string): string {
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
return `${prefix}-${stamp}-${crypto.randomBytes(6).toString("hex")}${ext}`;
}
-function imageExtFromBuffer(buf, fallback = ".jpg") {
- if (buf.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {
+function imageExtFromBuffer(buf: Buffer, fallback = ".jpg"): string {
+ if (
+ buf
+ .subarray(0, 8)
+ .equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
+ ) {
return ".png";
}
if (buf.subarray(0, 3).equals(Buffer.from([0xff, 0xd8, 0xff]))) {
return ".jpg";
}
- if (buf.subarray(0, 6).toString("ascii") === "GIF87a" || buf.subarray(0, 6).toString("ascii") === "GIF89a") {
+ if (
+ buf.subarray(0, 6).toString("ascii") === "GIF87a" ||
+ buf.subarray(0, 6).toString("ascii") === "GIF89a"
+ ) {
return ".gif";
}
- if (buf.subarray(0, 4).toString("ascii") === "RIFF" && buf.subarray(8, 12).toString("ascii") === "WEBP") {
+ if (
+ buf.subarray(0, 4).toString("ascii") === "RIFF" &&
+ buf.subarray(8, 12).toString("ascii") === "WEBP"
+ ) {
return ".webp";
}
return fallback;
}
-function getImageMimeFromFilename(filePath) {
- return IMAGE_MIME_BY_EXT[path.extname(filePath).toLowerCase()] || "image/jpeg";
+function getImageMimeFromFilename(filePath: string): string {
+ return (
+ IMAGE_MIME_BY_EXT[path.extname(filePath).toLowerCase()] || "image/jpeg"
+ );
}
-function encryptAesEcb(plaintext, key) {
+function encryptAesEcb(plaintext: Buffer, key: Buffer): Buffer {
const cipher = crypto.createCipheriv("aes-128-ecb", key, null);
return Buffer.concat([cipher.update(plaintext), cipher.final()]);
}
-function decryptAesEcb(ciphertext, key) {
+function decryptAesEcb(ciphertext: Buffer, key: Buffer): Buffer {
const decipher = crypto.createDecipheriv("aes-128-ecb", key, null);
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
}
-function aesEcbPaddedSize(plaintextSize) {
+function aesEcbPaddedSize(plaintextSize: number): number {
return Math.ceil((plaintextSize + 1) / 16) * 16;
}
-function parseAesKey(aesKeyBase64, label) {
+function parseAesKey(aesKeyBase64: string, label: string): Buffer {
const decoded = Buffer.from(aesKeyBase64, "base64");
if (decoded.length === 16) {
return decoded;
}
- if (decoded.length === 32 && /^[0-9a-fA-F]{32}$/.test(decoded.toString("ascii"))) {
+ if (
+ decoded.length === 32 &&
+ /^[0-9a-fA-F]{32}$/.test(decoded.toString("ascii"))
+ ) {
return Buffer.from(decoded.toString("ascii"), "hex");
}
- throw new Error(`${label}: aes_key must decode to 16 raw bytes or a 32-char hex string`);
+ throw new Error(
+ `${label}: aes_key must decode to 16 raw bytes or a 32-char hex string`,
+ );
}
-function resolveCdnBaseUrl() {
- return DEFAULT_CDN_BASE_URL || state?.baseUrl || process.env.AGENTFORGE_WEIXIN_BASE_URL || DEFAULT_BASE_URL;
+function resolveCdnBaseUrl(): string {
+ return (
+ DEFAULT_CDN_BASE_URL ||
+ state?.baseUrl ||
+ process.env.AGENTFORGE_WEIXIN_BASE_URL ||
+ DEFAULT_BASE_URL
+ );
}
-function buildCdnDownloadUrl(encryptedQueryParam, cdnBaseUrl = resolveCdnBaseUrl()) {
+function buildCdnDownloadUrl(
+ encryptedQueryParam: string,
+ cdnBaseUrl: string = resolveCdnBaseUrl(),
+): string {
return `${cdnBaseUrl.replace(/\/$/, "")}/download?encrypted_query_param=${encodeURIComponent(encryptedQueryParam)}`;
}
-function buildCdnUploadUrl(uploadParam, filekey, cdnBaseUrl = resolveCdnBaseUrl()) {
+function buildCdnUploadUrl(
+ uploadParam: string,
+ filekey: string,
+ cdnBaseUrl: string = resolveCdnBaseUrl(),
+): string {
return `${cdnBaseUrl.replace(/\/$/, "")}/upload?encrypted_query_param=${encodeURIComponent(uploadParam)}&filekey=${encodeURIComponent(filekey)}`;
}
-async function fetchCdnBytes(url, label) {
+async function fetchCdnBytes(url: string, label: string): Promise {
const response = await fetch(url);
if (!response.ok) {
const body = await response.text().catch(() => "");
- throw new Error(`${label}: CDN download ${response.status} ${response.statusText}: ${body}`);
+ throw new Error(
+ `${label}: CDN download ${response.status} ${response.statusText}: ${body}`,
+ );
}
return Buffer.from(await response.arrayBuffer());
}
-async function downloadAndDecryptBuffer(encryptedQueryParam, aesKeyBase64, label, fullUrl) {
+async function downloadAndDecryptBuffer(
+ encryptedQueryParam: string,
+ aesKeyBase64: string,
+ label: string,
+ fullUrl?: string,
+): Promise {
const key = parseAesKey(aesKeyBase64, label);
const url = fullUrl || buildCdnDownloadUrl(encryptedQueryParam);
const encrypted = await fetchCdnBytes(url, label);
return decryptAesEcb(encrypted, key);
}
-async function downloadPlainCdnBuffer(encryptedQueryParam, label, fullUrl) {
+async function downloadPlainCdnBuffer(
+ encryptedQueryParam: string,
+ label: string,
+ fullUrl?: string,
+): Promise {
const url = fullUrl || buildCdnDownloadUrl(encryptedQueryParam);
return fetchCdnBytes(url, label);
}
-function loadState() {
+function loadState(): BridgeState {
try {
if (!fs.existsSync(ACCOUNT_FILE)) {
return {
@@ -153,10 +236,16 @@ function loadState() {
syncCursor: "",
};
}
- const parsed = JSON.parse(fs.readFileSync(ACCOUNT_FILE, "utf8"));
+ const parsed = JSON.parse(fs.readFileSync(ACCOUNT_FILE, "utf8")) as Record<
+ string,
+ string | undefined
+ >;
return {
accountId: ACCOUNT_ID_OVERRIDE || parsed.accountId || "",
- baseUrl: parsed.baseUrl || process.env.AGENTFORGE_WEIXIN_BASE_URL || DEFAULT_BASE_URL,
+ baseUrl:
+ parsed.baseUrl ||
+ process.env.AGENTFORGE_WEIXIN_BASE_URL ||
+ DEFAULT_BASE_URL,
token: parsed.token || "",
userId: parsed.userId || "",
syncCursor: parsed.syncCursor || "",
@@ -173,7 +262,7 @@ function loadState() {
}
}
-function saveState() {
+function saveState(): void {
ensureDataDir();
fs.writeFileSync(
ACCOUNT_FILE,
@@ -192,7 +281,7 @@ function saveState() {
);
}
-function clearSession() {
+function clearSession(): void {
state = {
...state,
token: "",
@@ -202,17 +291,17 @@ function clearSession() {
saveState();
}
-function ensureTrailingSlash(url) {
+function ensureTrailingSlash(url: string): string {
return url.endsWith("/") ? url : `${url}/`;
}
-function randomWechatUin() {
+function randomWechatUin(): string {
const uint32 = crypto.randomBytes(4).readUInt32BE(0);
return Buffer.from(String(uint32), "utf8").toString("base64");
}
-function buildHeaders(body, token) {
- const headers = {
+function buildHeaders(body: string, token: string): Record {
+ const headers: Record = {
"Content-Type": "application/json",
AuthorizationType: "ilink_bot_token",
"Content-Length": String(Buffer.byteLength(body, "utf8")),
@@ -224,17 +313,28 @@ function buildHeaders(body, token) {
return headers;
}
-async function postJson(endpoint, payload, token, timeoutMs = 15000) {
- const body = JSON.stringify({ ...payload, base_info: { channel_version: CHANNEL_VERSION } });
+async function postJson(
+ endpoint: string,
+ payload: Record,
+ token: string,
+ timeoutMs = 15000,
+): Promise {
+ const body = JSON.stringify({
+ ...payload,
+ base_info: { channel_version: CHANNEL_VERSION },
+ });
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
- const response = await fetch(new URL(endpoint, ensureTrailingSlash(state.baseUrl)), {
- method: "POST",
- headers: buildHeaders(body, token),
- body,
- signal: controller.signal,
- });
+ const response = await fetch(
+ new URL(endpoint, ensureTrailingSlash(state.baseUrl)),
+ {
+ method: "POST",
+ headers: buildHeaders(body, token),
+ body,
+ signal: controller.signal,
+ },
+ );
const raw = await response.text();
if (!response.ok) {
throw new Error(`${response.status} ${response.statusText}: ${raw}`);
@@ -245,7 +345,15 @@ async function postJson(endpoint, payload, token, timeoutMs = 15000) {
}
}
-async function getUploadUrl(params) {
+async function getUploadUrl(params: {
+ filekey: string;
+ media_type: number;
+ to_user_id: string;
+ rawsize: number;
+ rawfilemd5: string;
+ filesize: number;
+ aeskey: string;
+}): Promise {
return postJson(
"ilink/bot/getuploadurl",
{
@@ -263,15 +371,25 @@ async function getUploadUrl(params) {
);
}
-async function uploadBufferToCdn(params) {
+async function uploadBufferToCdn(params: {
+ buf: Buffer;
+ uploadFullUrl?: string;
+ uploadParam?: string;
+ filekey: string;
+ aeskey: Buffer;
+ label: string;
+}): Promise<{ downloadParam: string }> {
const ciphertext = encryptAesEcb(params.buf, params.aeskey);
- const uploadUrl = params.uploadFullUrl?.trim()
- || (params.uploadParam ? buildCdnUploadUrl(params.uploadParam, params.filekey) : "");
+ const uploadUrl =
+ params.uploadFullUrl?.trim() ||
+ (params.uploadParam
+ ? buildCdnUploadUrl(params.uploadParam, params.filekey)
+ : "");
if (!uploadUrl) {
throw new Error(`${params.label}: CDN upload URL missing`);
}
- let lastError = null;
+ let lastError: unknown = null;
for (let attempt = 1; attempt <= 3; attempt += 1) {
try {
const response = await fetch(uploadUrl, {
@@ -280,16 +398,23 @@ async function uploadBufferToCdn(params) {
body: new Uint8Array(ciphertext),
});
if (response.status >= 400 && response.status < 500) {
- const body = response.headers.get("x-error-message") || (await response.text());
- throw new Error(`${params.label}: CDN upload client error ${response.status}: ${body}`);
+ const body =
+ response.headers.get("x-error-message") || (await response.text());
+ throw new Error(
+ `${params.label}: CDN upload client error ${response.status}: ${body}`,
+ );
}
if (response.status !== 200) {
- const body = response.headers.get("x-error-message") || `status ${response.status}`;
+ const body =
+ response.headers.get("x-error-message") ||
+ `status ${response.status}`;
throw new Error(`${params.label}: CDN upload server error: ${body}`);
}
const downloadParam = response.headers.get("x-encrypted-param") || "";
if (!downloadParam) {
- throw new Error(`${params.label}: CDN response missing x-encrypted-param`);
+ throw new Error(
+ `${params.label}: CDN response missing x-encrypted-param`,
+ );
}
return { downloadParam };
} catch (error) {
@@ -298,14 +423,19 @@ async function uploadBufferToCdn(params) {
throw error;
}
if (attempt < 3) {
- log(`${params.label}: CDN upload attempt ${attempt} failed: ${String(error)}`);
+ log(
+ `${params.label}: CDN upload attempt ${attempt} failed: ${String(error)}`,
+ );
}
}
}
throw lastError || new Error(`${params.label}: CDN upload failed`);
}
-async function uploadImageToWeixin(filePath, toUserId) {
+async function uploadImageToWeixin(
+ filePath: string,
+ toUserId: string,
+): Promise {
const plaintext = fs.readFileSync(filePath);
const rawsize = plaintext.length;
const rawfilemd5 = crypto.createHash("md5").update(plaintext).digest("hex");
@@ -338,7 +468,7 @@ async function uploadImageToWeixin(filePath, toUserId) {
};
}
-function buildImageItem(uploaded) {
+function buildImageItem(uploaded: UploadedImage): Record {
return {
type: MESSAGE_ITEM_TYPE.IMAGE,
image_item: {
@@ -352,11 +482,14 @@ function buildImageItem(uploaded) {
};
}
-async function fetchQrCode() {
+async function fetchQrCode(): Promise {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 15000);
try {
- const url = new URL(`ilink/bot/get_bot_qrcode?bot_type=${encodeURIComponent(BOT_TYPE)}`, ensureTrailingSlash(INITIAL_BASE_URL));
+ const url = new URL(
+ `ilink/bot/get_bot_qrcode?bot_type=${encodeURIComponent(BOT_TYPE)}`,
+ ensureTrailingSlash(INITIAL_BASE_URL),
+ );
log(`fetchQrCode: GET ${url}`);
const response = await fetch(url, { signal: controller.signal });
log(`fetchQrCode: status=${response.status} ok=${response.ok}`);
@@ -366,18 +499,26 @@ async function fetchQrCode() {
throw new Error(`${response.status} ${response.statusText}: ${raw}`);
}
const data = JSON.parse(raw);
- log(`fetchQrCode: qrcode=${data?.qrcode || "MISSING"} img_len=${String(data?.qrcode_img_content || "").length}`);
+ log(
+ `fetchQrCode: qrcode=${data?.qrcode || "MISSING"} img_len=${String(data?.qrcode_img_content || "").length}`,
+ );
return data;
} finally {
clearTimeout(timeout);
}
}
-async function pollQrStatus(qrcode, baseUrl = INITIAL_BASE_URL) {
+async function pollQrStatus(
+ qrcode: string,
+ baseUrl: string = INITIAL_BASE_URL,
+): Promise {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 35000);
try {
- const url = new URL(`ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(qrcode)}`, ensureTrailingSlash(baseUrl));
+ const url = new URL(
+ `ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(qrcode)}`,
+ ensureTrailingSlash(baseUrl),
+ );
const response = await fetch(url, {
headers: { "iLink-App-ClientVersion": "1" },
signal: controller.signal,
@@ -397,8 +538,8 @@ async function pollQrStatus(qrcode, baseUrl = INITIAL_BASE_URL) {
}
}
-function extractText(itemList = []) {
- const parts = [];
+function extractText(itemList: any[] = []): string {
+ const parts: string[] = [];
for (const item of itemList) {
if (item?.type === MESSAGE_ITEM_TYPE.TEXT && item.text_item?.text) {
parts.push(String(item.text_item.text));
@@ -409,7 +550,7 @@ function extractText(itemList = []) {
return parts.join("\n").trim();
}
-function extractReplyToMessageId(itemList = []) {
+function extractReplyToMessageId(itemList: any[] = []): string {
for (const item of itemList) {
const refMessageId = item?.ref_msg?.message_item?.msg_id;
if (refMessageId) {
@@ -419,14 +560,20 @@ function extractReplyToMessageId(itemList = []) {
return "";
}
-function extractReplyReference(itemList = []) {
+function extractReplyReference(itemList: any[] = []): {
+ messageId: string;
+ title: string;
+ text: string;
+} {
for (const item of itemList) {
const ref = item?.ref_msg;
if (!ref) {
continue;
}
return {
- messageId: ref?.message_item?.msg_id ? String(ref.message_item.msg_id) : "",
+ messageId: ref?.message_item?.msg_id
+ ? String(ref.message_item.msg_id)
+ : "",
title: ref?.title ? String(ref.title) : "",
text: ref?.message_item ? extractText([ref.message_item]) : "",
};
@@ -434,7 +581,9 @@ function extractReplyReference(itemList = []) {
return { messageId: "", title: "", text: "" };
}
-async function downloadInboundImage(item) {
+async function downloadInboundImage(
+ item: any,
+): Promise<{ path: string; media_type: string; size: number } | null> {
const imageItem = item?.image_item;
const media = imageItem?.media;
if (!media?.encrypt_query_param && !media?.full_url) {
@@ -445,10 +594,22 @@ async function downloadInboundImage(item) {
: media.aes_key;
const label = "inbound image";
const buf = aesKeyBase64
- ? await downloadAndDecryptBuffer(media.encrypt_query_param || "", aesKeyBase64, label, media.full_url)
- : await downloadPlainCdnBuffer(media.encrypt_query_param || "", label, media.full_url);
+ ? await downloadAndDecryptBuffer(
+ media.encrypt_query_param || "",
+ aesKeyBase64,
+ label,
+ media.full_url,
+ )
+ : await downloadPlainCdnBuffer(
+ media.encrypt_query_param || "",
+ label,
+ media.full_url,
+ );
const ext = imageExtFromBuffer(buf);
- const filePath = path.join(ensureMediaDir("inbound"), mediaFileName("weixin-inbound", ext));
+ const filePath = path.join(
+ ensureMediaDir("inbound"),
+ mediaFileName("weixin-inbound", ext),
+ );
fs.writeFileSync(filePath, buf);
return {
path: filePath,
@@ -457,9 +618,12 @@ async function downloadInboundImage(item) {
};
}
-async function extractInboundImages(itemList = []) {
- const images = [];
- const errors = [];
+async function extractInboundImages(itemList: any[] = []): Promise<{
+ images: Array<{ path: string; media_type: string; size: number }>;
+ errors: string[];
+}> {
+ const images: Array<{ path: string; media_type: string; size: number }> = [];
+ const errors: string[] = [];
for (const item of itemList) {
if (item?.type !== MESSAGE_ITEM_TYPE.IMAGE) {
continue;
@@ -478,7 +642,7 @@ async function extractInboundImages(itemList = []) {
return { images, errors };
}
-function extractQuotedMessageId(msg) {
+function extractQuotedMessageId(msg: any): string {
for (const item of msg?.item_list || []) {
if (item?.msg_id) {
return String(item.msg_id);
@@ -490,7 +654,7 @@ function extractQuotedMessageId(msg) {
return "";
}
-function maybeEmitSentConfirmation(msg) {
+function maybeEmitSentConfirmation(msg: any): void {
const clientId = String(msg?.client_id || "");
if (!clientId) {
return;
@@ -513,7 +677,9 @@ function maybeEmitSentConfirmation(msg) {
});
}
-async function normalizeInboundMessage(msg) {
+async function normalizeInboundMessage(
+ msg: any,
+): Promise | null> {
if (msg?.message_type !== MESSAGE_TYPE.USER) {
return null;
}
@@ -541,7 +707,10 @@ async function normalizeInboundMessage(msg) {
};
}
-async function sendMessageItem(command, item) {
+async function sendMessageItem(
+ command: BridgeCommand,
+ item: Record,
+): Promise {
if (!state.token) {
throw new Error("weixin account is not logged in");
}
@@ -575,16 +744,19 @@ async function sendMessageItem(command, item) {
return messageId;
}
-async function sendTextMessage(command) {
+async function sendTextMessage(command: BridgeCommand): Promise {
return sendMessageItem(command, {
type: MESSAGE_ITEM_TYPE.TEXT,
text_item: { text: command.text || "" },
});
}
-async function sendMessageWithImages(command) {
+async function sendMessageWithImages(command: BridgeCommand): Promise {
const imagePaths = Array.isArray(command.image_paths)
- ? command.image_paths.filter((imagePath) => typeof imagePath === "string" && imagePath)
+ ? command.image_paths.filter(
+ (imagePath): imagePath is string =>
+ typeof imagePath === "string" && Boolean(imagePath),
+ )
: [];
if (!imagePaths.length) {
await sendTextMessage(command);
@@ -595,12 +767,15 @@ async function sendMessageWithImages(command) {
await sendTextMessage(command);
}
for (const imagePath of imagePaths) {
- const uploaded = await uploadImageToWeixin(imagePath, command.peer_id);
+ const uploaded = await uploadImageToWeixin(
+ imagePath,
+ command.peer_id || "",
+ );
await sendMessageItem(command, buildImageItem(uploaded));
}
}
-async function pollUpdatesOnce() {
+async function pollUpdatesOnce(): Promise {
if (!state.token || shuttingDown) {
return;
}
@@ -638,7 +813,7 @@ async function pollUpdatesOnce() {
}
}
-async function pollLoop() {
+async function pollLoop(): Promise {
if (pollerStarted) {
return;
}
@@ -649,7 +824,7 @@ async function pollLoop() {
await pollUpdatesOnce();
} catch (error) {
emit({ type: "error", message: String(error) });
- await new Promise((resolve) => {
+ await new Promise((resolve) => {
pollTimer = setTimeout(resolve, 2000);
});
}
@@ -657,15 +832,17 @@ async function pollLoop() {
pollerStarted = false;
}
-async function startPollingIfReady() {
+async function startPollingIfReady(): Promise {
if (state.token && !pollerStarted) {
void pollLoop();
}
}
-async function loginFlow() {
+async function loginFlow(): Promise {
const MAX_QR_RETRIES = 3;
- log(`loginFlow: start INITIAL_BASE_URL=${INITIAL_BASE_URL} state.baseUrl=${state.baseUrl}`);
+ log(
+ `loginFlow: start INITIAL_BASE_URL=${INITIAL_BASE_URL} state.baseUrl=${state.baseUrl}`,
+ );
try {
for (let attempt = 0; attempt < MAX_QR_RETRIES; attempt++) {
log(`loginFlow: fetchQrCode attempt ${attempt + 1}/${MAX_QR_RETRIES}`);
@@ -691,7 +868,8 @@ async function loginFlow() {
if (status?.status === "confirmed" && status?.bot_token) {
state = {
...state,
- accountId: ACCOUNT_ID_OVERRIDE || status.ilink_bot_id || state.accountId,
+ accountId:
+ ACCOUNT_ID_OVERRIDE || status.ilink_bot_id || state.accountId,
baseUrl: status.baseurl || state.baseUrl,
token: status.bot_token,
userId: status.ilink_user_id || state.userId,
@@ -709,7 +887,9 @@ async function loginFlow() {
if (status?.status === "scaned_but_redirect") {
const redirect = String(status.redirect_host || "").trim();
if (redirect) {
- currentPollBaseUrl = redirect.startsWith("http") ? redirect : `https://${redirect}`;
+ currentPollBaseUrl = redirect.startsWith("http")
+ ? redirect
+ : `https://${redirect}`;
}
continue;
}
@@ -724,7 +904,9 @@ async function loginFlow() {
if (!expired) return;
if (attempt + 1 < MAX_QR_RETRIES) {
- log(`QR code expired, retrying (attempt ${attempt + 2}/${MAX_QR_RETRIES})`);
+ log(
+ `QR code expired, retrying (attempt ${attempt + 2}/${MAX_QR_RETRIES})`,
+ );
}
}
throw new Error("QR code expired");
@@ -736,7 +918,7 @@ async function loginFlow() {
}
}
-async function ensureLogin() {
+async function ensureLogin(): Promise {
if (loginInFlight) {
log("ensureLogin: login already in flight, reusing");
return loginInFlight;
@@ -746,7 +928,7 @@ async function ensureLogin() {
return loginInFlight;
}
-async function handleCommand(command) {
+async function handleCommand(command: BridgeCommand): Promise {
if (!command?.type) {
return;
}
@@ -757,18 +939,26 @@ async function handleCommand(command) {
}
if (command.type === "login") {
- log(`handleCommand: login — token=${state.token ? "present" : "absent"} baseUrl=${state.baseUrl} loginInFlight=${loginInFlight != null}`);
+ log(
+ `handleCommand: login — token=${state.token ? "present" : "absent"} baseUrl=${state.baseUrl} loginInFlight=${loginInFlight != null}`,
+ );
clearSession();
- log(`handleCommand: login — session cleared, INITIAL_BASE_URL=${INITIAL_BASE_URL}`);
+ log(
+ `handleCommand: login — session cleared, INITIAL_BASE_URL=${INITIAL_BASE_URL}`,
+ );
await ensureLogin();
return;
}
if (command.type === "logout") {
- log(`handleCommand: logout — token=${state.token ? "present" : "absent"} baseUrl=${state.baseUrl} loginInFlight=${loginInFlight != null}`);
+ log(
+ `handleCommand: logout — token=${state.token ? "present" : "absent"} baseUrl=${state.baseUrl} loginInFlight=${loginInFlight != null}`,
+ );
loginInFlight = null;
clearSession();
- log(`handleCommand: logout — session cleared, INITIAL_BASE_URL=${INITIAL_BASE_URL}`);
+ log(
+ `handleCommand: logout — session cleared, INITIAL_BASE_URL=${INITIAL_BASE_URL}`,
+ );
emit({ type: "logged_out" });
await ensureLogin();
}
@@ -781,11 +971,11 @@ const rl = readline.createInterface({
crlfDelay: Infinity,
});
-rl.on("line", (line) => {
+rl.on("line", (line: string) => {
if (!line.trim()) {
return;
}
- let command;
+ let command: BridgeCommand;
try {
command = JSON.parse(line);
} catch {
@@ -822,3 +1012,8 @@ if (state.token) {
} else if (AUTO_LOGIN) {
void ensureLogin();
}
+
+// `extractReplyToMessageId` was unused in the original index.mjs as well;
+// kept (and referenced here) so the port stays 1:1 without tripping
+// noUnusedLocals-style lint rules.
+void extractReplyToMessageId;
diff --git a/backend/src/db.ts b/backend/src/db.ts
new file mode 100644
index 0000000..282eab5
--- /dev/null
+++ b/backend/src/db.ts
@@ -0,0 +1,1471 @@
+// TaskDB: SQLite persistence layer, ported from taskboard.py (class TaskDB).
+// Method names, SQL, column names and defaults intentionally mirror the Python
+// source. Bun is single-threaded, so Python's RLock has no TS equivalent.
+
+import { Database } from "bun:sqlite";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { CronExpressionParser } from "cron-parser";
+import { logger } from "./log.ts";
+import {
+ dateToLocalIso,
+ normalizeDatetimeForStorage,
+ nowIso,
+ parseComparableDatetime,
+} from "./util.ts";
+import { HeartbeatScheduleType, type Heartbeat, type Task } from "./types.ts";
+
+type Row = Record;
+
+function expandUser(p: string): string {
+ return p.startsWith("~") ? path.join(os.homedir(), p.slice(1)) : p;
+}
+
+export class TaskDB {
+ db_path: string;
+ conn: Database;
+
+ constructor(db_path: string = "~/.agentforge/tasks.db") {
+ this.db_path = expandUser(db_path);
+ fs.mkdirSync(path.dirname(this.db_path), { recursive: true });
+ this.conn = new Database(this.db_path, { create: true });
+ this._init_db();
+ }
+
+ /** Run a migration statement, ignoring "column already exists" errors. */
+ private _migrate(sql: string): void {
+ try {
+ this.conn.run(sql);
+ } catch {
+ // Column already exists
+ }
+ }
+
+ private _init_db(): void {
+ this.conn.run(`
+ CREATE TABLE IF NOT EXISTS tasks (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ title TEXT NOT NULL,
+ prompt TEXT NOT NULL,
+ working_dir TEXT DEFAULT '.',
+ status TEXT DEFAULT 'pending',
+ schedule_type TEXT DEFAULT 'immediate',
+ cron_expr TEXT,
+ delay_seconds INTEGER,
+ next_run_at TEXT,
+ last_run_at TEXT,
+ result TEXT,
+ error TEXT,
+ run_count INTEGER DEFAULT 0,
+ max_runs INTEGER,
+ created_at TEXT DEFAULT (datetime('now')),
+ updated_at TEXT DEFAULT (datetime('now')),
+ tags TEXT DEFAULT '',
+ agent TEXT DEFAULT 'codex',
+ question TEXT,
+ answer TEXT
+ )
+ `);
+ // Migrations for existing DBs (each is a no-op when the column exists)
+ this._migrate("ALTER TABLE tasks ADD COLUMN agent TEXT DEFAULT 'codex'");
+ // question/answer share one try block, mirroring Python: if `question`
+ // already exists the `answer` migration is skipped in the same way.
+ try {
+ this.conn.run("ALTER TABLE tasks ADD COLUMN question TEXT");
+ this.conn.run("ALTER TABLE tasks ADD COLUMN answer TEXT");
+ } catch {
+ // Columns already exist
+ }
+ this._migrate("ALTER TABLE tasks ADD COLUMN session_id TEXT");
+ this._migrate(
+ "ALTER TABLE tasks ADD COLUMN prompt_images TEXT DEFAULT '[]'",
+ );
+ this._migrate("ALTER TABLE tasks ADD COLUMN image_paths TEXT DEFAULT '[]'");
+ this._migrate("ALTER TABLE tasks ADD COLUMN notify_slack_channel TEXT");
+ this._migrate("ALTER TABLE tasks ADD COLUMN notify_telegram_chat_id TEXT");
+
+ this.conn.run(`
+ CREATE TABLE IF NOT EXISTS settings (
+ key TEXT PRIMARY KEY,
+ value TEXT
+ )
+ `);
+ this.conn.run(`
+ CREATE TABLE IF NOT EXISTS task_runs (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ task_id INTEGER NOT NULL,
+ started_at TEXT DEFAULT (datetime('now')),
+ finished_at TEXT,
+ status TEXT,
+ result TEXT,
+ error TEXT,
+ raw_output TEXT,
+ FOREIGN KEY (task_id) REFERENCES tasks(id)
+ )
+ `);
+ this._migrate("ALTER TABLE task_runs ADD COLUMN raw_output TEXT");
+
+ this.conn.run(`
+ CREATE TABLE IF NOT EXISTS heartbeats (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL,
+ enabled INTEGER NOT NULL DEFAULT 1,
+ working_dir TEXT DEFAULT '.',
+ schedule_type TEXT NOT NULL,
+ cron_expr TEXT,
+ interval_seconds INTEGER,
+ check_prompt TEXT NOT NULL,
+ action_prompt_template TEXT DEFAULT '',
+ default_agent TEXT DEFAULT 'codex',
+ cooldown_seconds INTEGER DEFAULT 0,
+ next_run_at TEXT,
+ last_tick_at TEXT,
+ last_decision TEXT,
+ last_error TEXT,
+ last_triggered_at TEXT,
+ last_dedupe_key TEXT,
+ created_at TEXT DEFAULT (datetime('now')),
+ updated_at TEXT DEFAULT (datetime('now'))
+ )
+ `);
+ this.conn.run(`
+ CREATE INDEX IF NOT EXISTS idx_heartbeats_next_run
+ ON heartbeats(enabled, next_run_at)
+ `);
+ this.conn.run(`
+ CREATE TABLE IF NOT EXISTS heartbeat_ticks (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ heartbeat_id INTEGER NOT NULL,
+ started_at TEXT NOT NULL,
+ finished_at TEXT,
+ status TEXT NOT NULL,
+ decision_type TEXT,
+ decision_payload TEXT,
+ task_id INTEGER,
+ raw_output TEXT,
+ error TEXT,
+ FOREIGN KEY (heartbeat_id) REFERENCES heartbeats(id),
+ FOREIGN KEY (task_id) REFERENCES tasks(id)
+ )
+ `);
+ this.conn.run(`
+ CREATE INDEX IF NOT EXISTS idx_heartbeat_ticks_heartbeat_id
+ ON heartbeat_ticks(heartbeat_id, started_at DESC)
+ `);
+ this._migrate("ALTER TABLE heartbeat_ticks ADD COLUMN raw_output TEXT");
+ this.conn.run(`
+ CREATE TABLE IF NOT EXISTS heartbeat_dedup (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ heartbeat_id INTEGER NOT NULL,
+ dedupe_key TEXT NOT NULL,
+ task_id INTEGER,
+ triggered_at TEXT NOT NULL,
+ FOREIGN KEY (heartbeat_id) REFERENCES heartbeats(id),
+ FOREIGN KEY (task_id) REFERENCES tasks(id),
+ UNIQUE(heartbeat_id, dedupe_key)
+ )
+ `);
+ this.conn.run(`
+ CREATE INDEX IF NOT EXISTS idx_heartbeat_dedup_heartbeat_id
+ ON heartbeat_dedup(heartbeat_id, triggered_at DESC)
+ `);
+
+ // Structured output recording
+ this.conn.run(`
+ CREATE TABLE IF NOT EXISTS task_output_events (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ task_id INTEGER NOT NULL,
+ run_id INTEGER NOT NULL,
+ event_type TEXT NOT NULL,
+ content TEXT NOT NULL,
+ timestamp TEXT DEFAULT (datetime('now')),
+ FOREIGN KEY (task_id) REFERENCES tasks(id),
+ FOREIGN KEY (run_id) REFERENCES task_runs(id)
+ )
+ `);
+ this.conn.run(`
+ CREATE INDEX IF NOT EXISTS idx_task_output_events_task_id
+ ON task_output_events(task_id)
+ `);
+ this.conn.run(`
+ CREATE INDEX IF NOT EXISTS idx_task_output_events_run_id
+ ON task_output_events(run_id)
+ `);
+ this.conn.run(`
+ CREATE INDEX IF NOT EXISTS idx_task_output_events_timestamp
+ ON task_output_events(timestamp)
+ `);
+
+ // DAG dependency table
+ this.conn.run(`
+ CREATE TABLE IF NOT EXISTS task_dependencies (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ task_id INTEGER NOT NULL,
+ depends_on_task_id INTEGER NOT NULL,
+ inject_result INTEGER DEFAULT 0,
+ created_at TEXT DEFAULT (datetime('now')),
+ FOREIGN KEY (task_id) REFERENCES tasks(id),
+ FOREIGN KEY (depends_on_task_id) REFERENCES tasks(id),
+ UNIQUE(task_id, depends_on_task_id)
+ )
+ `);
+ this.conn.run(`
+ CREATE INDEX IF NOT EXISTS idx_task_deps_task_id
+ ON task_dependencies(task_id)
+ `);
+ this.conn.run(`
+ CREATE INDEX IF NOT EXISTS idx_task_deps_depends_on
+ ON task_dependencies(depends_on_task_id)
+ `);
+
+ // Skill Library: cross-run pattern ledger. The sweep agent tallies
+ // recurring task patterns here (dedup by semantic pattern_key); once a
+ // pattern crosses the recurrence threshold it becomes a skill candidate.
+ this.conn.run(`
+ CREATE TABLE IF NOT EXISTS skill_patterns (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ pattern_key TEXT NOT NULL UNIQUE,
+ kind TEXT NOT NULL DEFAULT 'recipe',
+ summary TEXT NOT NULL DEFAULT '',
+ recurrence_count INTEGER NOT NULL DEFAULT 1,
+ first_seen TEXT DEFAULT (datetime('now')),
+ last_seen TEXT DEFAULT (datetime('now')),
+ contributing_task_ids TEXT NOT NULL DEFAULT '[]',
+ contributing_run_ids TEXT NOT NULL DEFAULT '[]',
+ status TEXT NOT NULL DEFAULT 'tracking',
+ promoted_skill_id INTEGER,
+ created_at TEXT DEFAULT (datetime('now')),
+ updated_at TEXT DEFAULT (datetime('now'))
+ )
+ `);
+ this.conn.run(`
+ CREATE INDEX IF NOT EXISTS idx_skill_patterns_status
+ ON skill_patterns(status, recurrence_count DESC)
+ `);
+ // Migration: per-run idempotency ledger so a run is only ever counted
+ // once (lets the manual sweep re-scan recent runs without inflating counts).
+ this._migrate(
+ "ALTER TABLE skill_patterns ADD COLUMN contributing_run_ids TEXT NOT NULL DEFAULT '[]'",
+ );
+ // Backfill run-id sets for pre-existing patterns from their tasks' completed
+ // runs, so a re-scan dedups against real run ids instead of re-counting them.
+ try {
+ const legacy = this.conn
+ .query(
+ "SELECT id, contributing_task_ids FROM skill_patterns " +
+ "WHERE contributing_run_ids IN ('[]', '') OR contributing_run_ids IS NULL",
+ )
+ .all() as Row[];
+ for (const row of legacy) {
+ let tids: any[];
+ try {
+ const parsed = JSON.parse(row["contributing_task_ids"]);
+ tids = Array.isArray(parsed) ? parsed : [];
+ } catch {
+ tids = [];
+ }
+ if (!tids.length) continue;
+ const placeholders = tids.map(() => "?").join(",");
+ const runRows = this.conn
+ .query(
+ `SELECT id FROM task_runs WHERE status = 'completed' AND task_id IN (${placeholders})`,
+ )
+ .all(...tids) as Row[];
+ const run_ids = runRows.map((r) => r["id"]);
+ if (run_ids.length) {
+ this.conn
+ .query(
+ "UPDATE skill_patterns SET contributing_run_ids = ? WHERE id = ?",
+ )
+ .run(JSON.stringify(run_ids), row["id"]);
+ }
+ }
+ } catch {
+ // table shape predates the run-id ledger; ignore
+ }
+
+ // Skill Library: registry of distilled, approved skills. The canonical
+ // SKILL.md lives at `path` (~/.claude/skills//SKILL.md) and is
+ // symlinked into ~/.agents/skills for codex. `enabled` toggles whether
+ // the symlinks exist (i.e. whether agents load it).
+ this.conn.run(`
+ CREATE TABLE IF NOT EXISTS skills (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL UNIQUE,
+ description TEXT NOT NULL DEFAULT '',
+ path TEXT NOT NULL,
+ source_pattern_key TEXT,
+ source_task_ids TEXT,
+ kind TEXT,
+ enabled INTEGER NOT NULL DEFAULT 1,
+ created_at TEXT DEFAULT (datetime('now'))
+ )
+ `);
+ // Skill Library: one pending draft per candidate pattern (agent-distilled
+ // SKILL.md awaiting human review/approval).
+ this.conn.run(`
+ CREATE TABLE IF NOT EXISTS skill_drafts (
+ pattern_id INTEGER PRIMARY KEY,
+ name TEXT DEFAULT '',
+ description TEXT DEFAULT '',
+ kind TEXT DEFAULT 'recipe',
+ body TEXT DEFAULT '',
+ status TEXT NOT NULL DEFAULT 'drafting',
+ error TEXT,
+ worthy INTEGER,
+ worthiness_reason TEXT DEFAULT '',
+ updated_at TEXT DEFAULT (datetime('now')),
+ FOREIGN KEY (pattern_id) REFERENCES skill_patterns(id)
+ )
+ `);
+ // Migration: add skill-creator worthiness judgment to existing draft tables
+ for (const [col, decl] of [
+ ["worthy", "INTEGER"],
+ ["worthiness_reason", "TEXT DEFAULT ''"],
+ ] as const) {
+ this._migrate(`ALTER TABLE skill_drafts ADD COLUMN ${col} ${decl}`);
+ }
+
+ this._migrate("ALTER TABLE tasks ADD COLUMN dag_id TEXT");
+ // Migration: add feishu_root_msg_id column for post-restart resume
+ this._migrate("ALTER TABLE tasks ADD COLUMN feishu_root_msg_id TEXT");
+
+ // On startup, reset any tasks left in 'running' state — they were
+ // interrupted by a process kill (e.g. hot reload) and will never
+ // self-transition to completed/failed without this reset.
+ const now = nowIso();
+ this.conn
+ .query(
+ `
+ UPDATE tasks
+ SET status = 'failed',
+ error = 'Interrupted: process was restarted while task was running',
+ updated_at = ?
+ WHERE status = 'running'
+ `,
+ )
+ .run(now);
+ // Also close out any open task_runs rows that have no finished_at
+ this.conn
+ .query(
+ `
+ UPDATE task_runs
+ SET status = 'failed',
+ finished_at = ?,
+ error = 'Interrupted: process was restarted while task was running'
+ WHERE finished_at IS NULL
+ `,
+ )
+ .run(now);
+ }
+
+ /**
+ * Run statements in an explicit transaction.
+ *
+ * On success the transaction is committed; on any exception it is rolled
+ * back and the exception re-raised. Callers must NOT issue COMMIT or
+ * ROLLBACK themselves inside the callback.
+ */
+ transaction(fn: () => T): T {
+ this.conn.run("BEGIN");
+ try {
+ const result = fn();
+ this.conn.run("COMMIT");
+ return result;
+ } catch (e) {
+ this.conn.run("ROLLBACK");
+ throw e;
+ }
+ }
+
+ add_task(task: Task): number {
+ const now = nowIso();
+ logger.debug(
+ `add_task called with image_paths: ${JSON.stringify(task.image_paths)}`,
+ );
+ const image_paths_json = JSON.stringify(task.image_paths);
+ logger.debug(`image_paths JSON: ${image_paths_json}`);
+ const cur = this.conn
+ .query(
+ `
+ INSERT INTO tasks (title, prompt, working_dir, status, schedule_type,
+ cron_expr, delay_seconds, next_run_at, max_runs, created_at, updated_at, tags, agent, prompt_images, image_paths, dag_id, feishu_root_msg_id)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ `,
+ )
+ .run(
+ task.title,
+ task.prompt,
+ task.working_dir,
+ task.status,
+ task.schedule_type,
+ task.cron_expr,
+ task.delay_seconds,
+ task.next_run_at,
+ task.max_runs,
+ now,
+ now,
+ task.tags,
+ task.agent,
+ JSON.stringify(task.prompt_images),
+ image_paths_json,
+ task.dag_id,
+ task.feishu_root_msg_id,
+ );
+ const task_id = Number(cur.lastInsertRowid);
+ logger.debug(`Task ${task_id} inserted with image_paths`);
+ return task_id;
+ }
+
+ /** Look up the most recent task created from a given Feishu root message ID. */
+ get_task_by_feishu_root_msg(root_msg_id: string): Row | null {
+ const row = this.conn
+ .query(
+ "SELECT * FROM tasks WHERE feishu_root_msg_id = ? ORDER BY id DESC LIMIT 1",
+ )
+ .get(root_msg_id) as Row | null;
+ return row ? { ...row } : null;
+ }
+
+ get_setting(key: string, defaultValue: string | null = null): string | null {
+ const row = this.conn
+ .query("SELECT value FROM settings WHERE key = ?")
+ .get(key) as Row | null;
+ return row ? row["value"] : defaultValue;
+ }
+
+ set_setting(key: string, value: string): void {
+ this.conn
+ .query("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)")
+ .run(key, value);
+ }
+
+ private _deserialize_heartbeat(row: Row): Row {
+ const d: Row = { ...row };
+ d["enabled"] = Boolean(d["enabled"]);
+ return d;
+ }
+
+ _compute_heartbeat_next_run_at(
+ heartbeat: Heartbeat,
+ now: Date | null = null,
+ ): string {
+ const base = now ?? new Date();
+ if (heartbeat.schedule_type === HeartbeatScheduleType.CRON) {
+ if (!heartbeat.cron_expr) {
+ throw new Error("cron heartbeat requires cron_expr");
+ }
+ return dateToLocalIso(
+ CronExpressionParser.parse(heartbeat.cron_expr, { currentDate: base })
+ .next()
+ .toDate(),
+ );
+ }
+ if (heartbeat.schedule_type === HeartbeatScheduleType.INTERVAL) {
+ if (!heartbeat.interval_seconds || heartbeat.interval_seconds <= 0) {
+ throw new Error("interval heartbeat requires interval_seconds > 0");
+ }
+ return dateToLocalIso(
+ new Date(
+ base.getTime() + Math.trunc(heartbeat.interval_seconds) * 1000,
+ ),
+ );
+ }
+ throw new Error(
+ `Unsupported heartbeat schedule_type: ${heartbeat.schedule_type}`,
+ );
+ }
+
+ add_heartbeat(heartbeat: Heartbeat): number {
+ const now = nowIso();
+ if (heartbeat.next_run_at === null) {
+ heartbeat.next_run_at = this._compute_heartbeat_next_run_at(
+ heartbeat,
+ new Date(),
+ );
+ }
+ const cur = this.conn
+ .query(
+ `
+ INSERT INTO heartbeats (
+ name, enabled, working_dir, schedule_type, cron_expr,
+ interval_seconds, check_prompt, action_prompt_template,
+ default_agent, cooldown_seconds, next_run_at, last_tick_at,
+ last_decision, last_error, last_triggered_at, last_dedupe_key,
+ created_at, updated_at
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ `,
+ )
+ .run(
+ heartbeat.name,
+ heartbeat.enabled ? 1 : 0,
+ heartbeat.working_dir,
+ heartbeat.schedule_type,
+ heartbeat.cron_expr,
+ heartbeat.interval_seconds,
+ heartbeat.check_prompt,
+ heartbeat.action_prompt_template,
+ heartbeat.default_agent,
+ heartbeat.cooldown_seconds,
+ heartbeat.next_run_at,
+ heartbeat.last_tick_at,
+ heartbeat.last_decision,
+ heartbeat.last_error,
+ heartbeat.last_triggered_at,
+ heartbeat.last_dedupe_key,
+ now,
+ now,
+ );
+ return Number(cur.lastInsertRowid);
+ }
+
+ static readonly ALLOWED_HEARTBEAT_COLUMNS: ReadonlySet = new Set([
+ "name",
+ "enabled",
+ "working_dir",
+ "schedule_type",
+ "cron_expr",
+ "interval_seconds",
+ "check_prompt",
+ "action_prompt_template",
+ "default_agent",
+ "cooldown_seconds",
+ "next_run_at",
+ "last_tick_at",
+ "last_decision",
+ "last_error",
+ "last_triggered_at",
+ "last_dedupe_key",
+ "updated_at",
+ ]);
+
+ update_heartbeat(
+ heartbeat_id: number,
+ kwargs: Record,
+ ): void {
+ const invalid = Object.keys(kwargs).filter(
+ (k) => !TaskDB.ALLOWED_HEARTBEAT_COLUMNS.has(k),
+ );
+ if (invalid.length) {
+ throw new Error(
+ `Invalid heartbeat column(s): ${JSON.stringify(invalid)}`,
+ );
+ }
+ const updates: Record = { ...kwargs };
+ updates["updated_at"] = nowIso();
+ const sets = Object.keys(updates)
+ .map((k) => `${k} = ?`)
+ .join(", ");
+ const vals = [...Object.values(updates), heartbeat_id];
+ this.conn
+ .query(`UPDATE heartbeats SET ${sets} WHERE id = ?`)
+ .run(...(vals as any[]));
+ }
+
+ get_heartbeat(heartbeat_id: number): Row | null {
+ const row = this.conn
+ .query("SELECT * FROM heartbeats WHERE id = ?")
+ .get(heartbeat_id) as Row | null;
+ return row ? this._deserialize_heartbeat(row) : null;
+ }
+
+ get_all_heartbeats(): Row[] {
+ const rows = this.conn
+ .query("SELECT * FROM heartbeats ORDER BY created_at DESC")
+ .all() as Row[];
+ return rows.map((r) => this._deserialize_heartbeat(r));
+ }
+
+ get_due_heartbeats(): Row[] {
+ const rows = this.conn
+ .query(
+ `
+ SELECT * FROM heartbeats
+ WHERE enabled = 1
+ AND next_run_at IS NOT NULL
+ `,
+ )
+ .all() as Row[];
+ const now = new Date();
+ const due: Row[] = [];
+ for (const row of rows) {
+ const heartbeat = this._deserialize_heartbeat(row);
+ let next_run_at: Date | null;
+ try {
+ next_run_at = parseComparableDatetime(heartbeat["next_run_at"]);
+ } catch {
+ continue;
+ }
+ if (next_run_at && next_run_at.getTime() <= now.getTime()) {
+ due.push(heartbeat);
+ }
+ }
+ return due;
+ }
+
+ delete_heartbeat(heartbeat_id: number): void {
+ this.transaction(() => {
+ this.conn
+ .query("DELETE FROM heartbeat_ticks WHERE heartbeat_id = ?")
+ .run(heartbeat_id);
+ this.conn
+ .query("DELETE FROM heartbeat_dedup WHERE heartbeat_id = ?")
+ .run(heartbeat_id);
+ this.conn.query("DELETE FROM heartbeats WHERE id = ?").run(heartbeat_id);
+ });
+ }
+
+ add_heartbeat_tick(heartbeat_id: number): number {
+ const cur = this.conn
+ .query(
+ `
+ INSERT INTO heartbeat_ticks (heartbeat_id, started_at, status)
+ VALUES (?, ?, 'running')
+ `,
+ )
+ .run(heartbeat_id, nowIso());
+ return Number(cur.lastInsertRowid);
+ }
+
+ finish_heartbeat_tick(
+ tick_id: number,
+ status: string,
+ decision_type: string | null = null,
+ decision_payload: Record | null = null,
+ task_id: number | null = null,
+ raw_output: string | null = null,
+ error: string | null = null,
+ ): void {
+ const payload_json =
+ decision_payload !== null ? JSON.stringify(decision_payload) : null;
+ this.conn
+ .query(
+ `
+ UPDATE heartbeat_ticks
+ SET finished_at = ?, status = ?, decision_type = ?, decision_payload = ?, task_id = ?, raw_output = ?, error = ?
+ WHERE id = ?
+ `,
+ )
+ .run(
+ nowIso(),
+ status,
+ decision_type,
+ payload_json,
+ task_id,
+ raw_output,
+ error,
+ tick_id,
+ );
+ }
+
+ get_heartbeat_ticks(heartbeat_id: number, limit: number = 50): Row[] {
+ const rows = this.conn
+ .query(
+ `
+ SELECT * FROM heartbeat_ticks
+ WHERE heartbeat_id = ?
+ ORDER BY started_at DESC
+ LIMIT ?
+ `,
+ )
+ .all(heartbeat_id, limit) as Row[];
+ return rows.map((r) => ({ ...r }));
+ }
+
+ get_heartbeat_tick(heartbeat_id: number, tick_id: number): Row | null {
+ const row = this.conn
+ .query(
+ `
+ SELECT * FROM heartbeat_ticks
+ WHERE heartbeat_id = ? AND id = ?
+ `,
+ )
+ .get(heartbeat_id, tick_id) as Row | null;
+ return row ? { ...row } : null;
+ }
+
+ get_latest_heartbeat_tick(heartbeat_id: number): Row | null {
+ const row = this.conn
+ .query(
+ `
+ SELECT * FROM heartbeat_ticks
+ WHERE heartbeat_id = ?
+ ORDER BY started_at DESC
+ LIMIT 1
+ `,
+ )
+ .get(heartbeat_id) as Row | null;
+ return row ? { ...row } : null;
+ }
+
+ get_heartbeat_dedup(heartbeat_id: number, dedupe_key: string): Row | null {
+ const row = this.conn
+ .query(
+ `
+ SELECT * FROM heartbeat_dedup
+ WHERE heartbeat_id = ? AND dedupe_key = ?
+ `,
+ )
+ .get(heartbeat_id, dedupe_key) as Row | null;
+ return row ? { ...row } : null;
+ }
+
+ upsert_heartbeat_dedup(
+ heartbeat_id: number,
+ dedupe_key: string,
+ task_id: number | null,
+ ): void {
+ const now = nowIso();
+ this.conn
+ .query(
+ `
+ INSERT INTO heartbeat_dedup (heartbeat_id, dedupe_key, task_id, triggered_at)
+ VALUES (?, ?, ?, ?)
+ ON CONFLICT(heartbeat_id, dedupe_key)
+ DO UPDATE SET task_id = excluded.task_id, triggered_at = excluded.triggered_at
+ `,
+ )
+ .run(heartbeat_id, dedupe_key, task_id, now);
+ }
+
+ static readonly ALLOWED_TASK_COLUMNS: ReadonlySet = new Set([
+ "title",
+ "prompt",
+ "working_dir",
+ "status",
+ "schedule_type",
+ "cron_expr",
+ "delay_seconds",
+ "next_run_at",
+ "last_run_at",
+ "result",
+ "error",
+ "run_count",
+ "max_runs",
+ "updated_at",
+ "tags",
+ "agent",
+ "question",
+ "answer",
+ "session_id",
+ "prompt_images",
+ "image_paths",
+ "dag_id",
+ ]);
+
+ update_task(task_id: number, kwargs: Record): void {
+ const invalid = Object.keys(kwargs).filter(
+ (k) => !TaskDB.ALLOWED_TASK_COLUMNS.has(k),
+ );
+ if (invalid.length) {
+ throw new Error(`Invalid task column(s): ${JSON.stringify(invalid)}`);
+ }
+ const updates: Record = { ...kwargs };
+ if ("next_run_at" in updates) {
+ updates["next_run_at"] = normalizeDatetimeForStorage(
+ updates["next_run_at"] as string | null | undefined,
+ );
+ }
+ updates["updated_at"] = nowIso();
+ const sets = Object.keys(updates)
+ .map((k) => `${k} = ?`)
+ .join(", ");
+ const vals = [...Object.values(updates), task_id];
+ this.conn
+ .query(`UPDATE tasks SET ${sets} WHERE id = ?`)
+ .run(...(vals as any[]));
+ }
+
+ private _deserialize_task(row: Row): Row {
+ const d: Row = { ...row };
+ // Deserialize prompt_images
+ const raw = d["prompt_images"];
+ if (typeof raw === "string") {
+ try {
+ d["prompt_images"] = JSON.parse(raw);
+ } catch {
+ d["prompt_images"] = [];
+ }
+ } else if (
+ d["prompt_images"] === null ||
+ d["prompt_images"] === undefined
+ ) {
+ d["prompt_images"] = [];
+ }
+ // Deserialize image_paths
+ const raw_paths = d["image_paths"];
+ if (typeof raw_paths === "string") {
+ try {
+ d["image_paths"] = JSON.parse(raw_paths);
+ } catch {
+ d["image_paths"] = [];
+ }
+ } else if (d["image_paths"] === null || d["image_paths"] === undefined) {
+ d["image_paths"] = [];
+ }
+ return d;
+ }
+
+ get_task(task_id: number): Row | null {
+ const row = this.conn
+ .query("SELECT * FROM tasks WHERE id = ?")
+ .get(task_id) as Row | null;
+ return row ? this._deserialize_task(row) : null;
+ }
+
+ get_all_tasks(): Row[] {
+ const rows = this.conn
+ .query("SELECT * FROM tasks ORDER BY created_at DESC")
+ .all() as Row[];
+ return rows.map((r) => this._deserialize_task(r));
+ }
+
+ get_due_tasks(): Row[] {
+ const rows = this.conn
+ .query(
+ `
+ SELECT * FROM tasks
+ WHERE status IN ('pending', 'scheduled')
+ `,
+ )
+ .all() as Row[];
+ const now = new Date();
+ const due: Row[] = [];
+ for (const row of rows) {
+ const task = this._deserialize_task(row);
+ let next_run_at: Date | null;
+ try {
+ next_run_at = parseComparableDatetime(task["next_run_at"]);
+ } catch {
+ continue;
+ }
+ if (next_run_at === null || next_run_at.getTime() <= now.getTime()) {
+ due.push(task);
+ }
+ }
+ return due;
+ }
+
+ add_run(task_id: number): number {
+ const cur = this.conn
+ .query("INSERT INTO task_runs (task_id, status) VALUES (?, 'running')")
+ .run(task_id);
+ return Number(cur.lastInsertRowid);
+ }
+
+ finish_run(
+ run_id: number,
+ status: string,
+ result: string | null = null,
+ error: string | null = null,
+ raw_output: string | null = null,
+ ): void {
+ this.conn
+ .query(
+ `
+ UPDATE task_runs SET finished_at = datetime('now'),
+ status = ?, result = ?, error = ?, raw_output = ?
+ WHERE id = ?
+ `,
+ )
+ .run(status, result, error, raw_output, run_id);
+ }
+
+ /** Atomically finish a run record and update the parent task in one transaction. */
+ finish_run_and_update_task(
+ run_id: number,
+ run_status: string,
+ task_id: number,
+ task_updates: Record,
+ run_result: string | null = null,
+ run_error: string | null = null,
+ raw_output: string | null = null,
+ ): void {
+ const updates: Record = { ...task_updates };
+ updates["updated_at"] = nowIso();
+ const sets = Object.keys(updates)
+ .map((k) => `${k} = ?`)
+ .join(", ");
+ const vals = [...Object.values(updates), task_id];
+ this.transaction(() => {
+ this.conn
+ .query(
+ `
+ UPDATE task_runs SET finished_at = datetime('now'),
+ status = ?, result = ?, error = ?, raw_output = ?
+ WHERE id = ?
+ `,
+ )
+ .run(run_status, run_result, run_error, raw_output, run_id);
+ this.conn
+ .query(`UPDATE tasks SET ${sets} WHERE id = ?`)
+ .run(...(vals as any[]));
+ });
+ }
+
+ get_task_runs(task_id: number, limit: number = 20): Row[] {
+ const rows = this.conn
+ .query(
+ `
+ SELECT * FROM task_runs WHERE task_id = ?
+ ORDER BY started_at DESC LIMIT ?
+ `,
+ )
+ .all(task_id, limit) as Row[];
+ return rows.map((r) => ({ ...r }));
+ }
+
+ /** Add a new output event to the database. */
+ add_output_event(
+ task_id: number,
+ run_id: number,
+ event_type: string,
+ content: string,
+ ): void {
+ this.conn
+ .query(
+ `
+ INSERT INTO task_output_events (task_id, run_id, event_type, content)
+ VALUES (?, ?, ?, ?)
+ `,
+ )
+ .run(task_id, run_id, event_type, content);
+ }
+
+ /** Get output events for a task, ordered by timestamp. */
+ get_output_events(
+ task_id: number,
+ limit: number = 1000,
+ offset: number = 0,
+ ): Row[] {
+ const rows = this.conn
+ .query(
+ `
+ SELECT * FROM task_output_events
+ WHERE task_id = ?
+ ORDER BY timestamp DESC
+ LIMIT ? OFFSET ?
+ `,
+ )
+ .all(task_id, limit, offset) as Row[];
+ return rows.map((r) => ({ ...r }));
+ }
+
+ /** Get output events for a specific run. */
+ get_run_output_events(run_id: number, limit: number = 1000): Row[] {
+ const rows = this.conn
+ .query(
+ `
+ SELECT * FROM task_output_events
+ WHERE run_id = ?
+ ORDER BY timestamp ASC
+ LIMIT ?
+ `,
+ )
+ .all(run_id, limit) as Row[];
+ return rows.map((r) => ({ ...r }));
+ }
+
+ // ── Skill Library: pattern ledger ──────────────────────────────────────
+
+ /**
+ * Completed task runs finished after `watermark`, oldest first.
+ *
+ * Joined with task metadata so the sweep agent can read what each run did.
+ * Ordering ASC + limit makes the watermark advance incrementally so a large
+ * backlog is processed across several sweeps rather than all at once.
+ */
+ get_completed_runs_since(watermark: string, limit: number = 50): Row[] {
+ const rows = this.conn
+ .query(
+ `
+ SELECT r.id AS run_id, r.task_id, r.finished_at, r.result,
+ t.title, t.prompt, t.working_dir, t.agent
+ FROM task_runs r
+ JOIN tasks t ON t.id = r.task_id
+ WHERE r.status = 'completed'
+ AND r.finished_at IS NOT NULL
+ AND r.finished_at > ?
+ ORDER BY r.finished_at ASC
+ LIMIT ?
+ `,
+ )
+ .all(watermark || "", limit) as Row[];
+ return rows.map((r) => ({ ...r }));
+ }
+
+ /** The most recent completed runs regardless of watermark (manual re-scan). */
+ get_recent_completed_runs(limit: number = 100): Row[] {
+ const rows = this.conn
+ .query(
+ `
+ SELECT r.id AS run_id, r.task_id, r.finished_at, r.result,
+ t.title, t.prompt, t.working_dir, t.agent
+ FROM task_runs r
+ JOIN tasks t ON t.id = r.task_id
+ WHERE r.status = 'completed' AND r.finished_at IS NOT NULL
+ ORDER BY r.finished_at DESC
+ LIMIT ?
+ `,
+ )
+ .all(limit) as Row[];
+ // Return oldest-first so watermark math stays consistent.
+ return rows.reverse().map((r) => ({ ...r }));
+ }
+
+ /**
+ * Record one observation of a pattern. Dedup by exact pattern_key.
+ *
+ * Semantic matching is done by the sweep agent (it reuses an existing key).
+ * Counting is idempotent per run: if `run_id` was already counted for this
+ * pattern, only the summary/last_seen refresh — recurrence does NOT bump.
+ * This lets the manual sweep re-scan recent runs without inflating counts.
+ * When run_id is None (legacy / unknown), fall back to bumping per call.
+ */
+ upsert_skill_pattern(
+ pattern_key: string,
+ kind: string,
+ summary: string,
+ task_id: number | null,
+ run_id: number | null = null,
+ ): number | null {
+ pattern_key = (pattern_key || "").trim();
+ if (!pattern_key) {
+ return null;
+ }
+ kind = kind === "recipe" || kind === "pitfall" ? kind : "recipe";
+ const now = nowIso();
+ const row = this.conn
+ .query(
+ "SELECT id, contributing_task_ids, contributing_run_ids " +
+ "FROM skill_patterns WHERE pattern_key = ?",
+ )
+ .get(pattern_key) as Row | null;
+ if (row) {
+ let tids: any[];
+ try {
+ const parsed = JSON.parse(row["contributing_task_ids"]);
+ tids = Array.isArray(parsed) ? [...parsed] : [];
+ } catch {
+ tids = [];
+ }
+ let rids: any[];
+ try {
+ const parsed = JSON.parse(row["contributing_run_ids"] || "[]");
+ rids = Array.isArray(parsed) ? [...parsed] : [];
+ } catch {
+ rids = [];
+ }
+ const already_counted = run_id !== null && rids.includes(run_id);
+ if (task_id !== null && !tids.includes(task_id)) {
+ tids.push(task_id);
+ }
+ if (run_id !== null && !rids.includes(run_id)) {
+ rids.push(run_id);
+ }
+ // Bump only for a genuinely new observation.
+ const bump = already_counted ? 0 : 1;
+ this.conn
+ .query(
+ `
+ UPDATE skill_patterns
+ SET recurrence_count = recurrence_count + ?,
+ last_seen = ?,
+ updated_at = ?,
+ summary = CASE WHEN ? != '' THEN ? ELSE summary END,
+ contributing_task_ids = ?,
+ contributing_run_ids = ?
+ WHERE id = ?
+ `,
+ )
+ .run(
+ bump,
+ now,
+ now,
+ summary || "",
+ summary || "",
+ JSON.stringify(tids),
+ JSON.stringify(rids),
+ row["id"],
+ );
+ return Number(row["id"]);
+ }
+ const tids = task_id !== null ? [task_id] : [];
+ const rids = run_id !== null ? [run_id] : [];
+ const cur = this.conn
+ .query(
+ `
+ INSERT INTO skill_patterns
+ (pattern_key, kind, summary, recurrence_count,
+ first_seen, last_seen, contributing_task_ids, contributing_run_ids, status)
+ VALUES (?, ?, ?, 1, ?, ?, ?, ?, 'tracking')
+ `,
+ )
+ .run(
+ pattern_key,
+ kind,
+ summary || "",
+ now,
+ now,
+ JSON.stringify(tids),
+ JSON.stringify(rids),
+ );
+ return Number(cur.lastInsertRowid);
+ }
+
+ get_skill_patterns(limit: number = 200): Row[] {
+ const rows = this.conn
+ .query(
+ `
+ SELECT p.*, d.status AS draft_status, d.name AS draft_name,
+ d.description AS draft_description, d.kind AS draft_kind,
+ d.body AS draft_body, d.error AS draft_error,
+ d.worthy AS draft_worthy, d.worthiness_reason AS draft_worthiness_reason
+ FROM skill_patterns p
+ LEFT JOIN skill_drafts d ON d.pattern_id = p.id
+ ORDER BY p.recurrence_count DESC, p.last_seen DESC
+ LIMIT ?
+ `,
+ )
+ .all(limit) as Row[];
+ return rows.map((r) => ({ ...r }));
+ }
+
+ get_skill_pattern(pattern_id: number): Row | null {
+ const row = this.conn
+ .query("SELECT * FROM skill_patterns WHERE id = ?")
+ .get(pattern_id) as Row | null;
+ return row ? { ...row } : null;
+ }
+
+ /** Current recurrence_count for a pattern_key (0 if it doesn't exist yet). */
+ get_skill_pattern_recurrence(pattern_key: string): number {
+ pattern_key = (pattern_key || "").trim();
+ if (!pattern_key) {
+ return 0;
+ }
+ const row = this.conn
+ .query(
+ "SELECT recurrence_count FROM skill_patterns WHERE pattern_key = ?",
+ )
+ .get(pattern_key) as Row | null;
+ return row ? row["recurrence_count"] : 0;
+ }
+
+ /**
+ * True if the pattern's recurrences cluster within `window_days`.
+ *
+ * Tolerant: if timestamps can't be parsed, don't block promotion.
+ */
+ static _within_window(
+ first_seen: string,
+ last_seen: string,
+ window_days: number,
+ ): boolean {
+ let f: Date | null;
+ let ls: Date | null;
+ try {
+ f = parseComparableDatetime(first_seen);
+ ls = parseComparableDatetime(last_seen);
+ } catch {
+ return true;
+ }
+ if (f === null || ls === null) {
+ return true;
+ }
+ // ≙ Python timedelta.days (floor division of the difference)
+ return Math.floor((ls.getTime() - f.getTime()) / 86_400_000) <= window_days;
+ }
+
+ /**
+ * Promote 'tracking' patterns that cross the threshold to 'candidate'.
+ *
+ * Threshold (borrowed from pskoett self-improvement): recurrence >= 3 AND
+ * >= 2 distinct tasks AND recurrences within a 30-day window. Returns the
+ * number newly marked.
+ */
+ refresh_skill_candidates(
+ min_recurrence: number = 3,
+ min_tasks: number = 2,
+ window_days: number = 30,
+ ): number {
+ let marked = 0;
+ const now = nowIso();
+ const rows = this.conn
+ .query(
+ `
+ SELECT id, recurrence_count, contributing_task_ids, first_seen, last_seen
+ FROM skill_patterns WHERE status = 'tracking'
+ `,
+ )
+ .all() as Row[];
+ for (const r of rows) {
+ if (r["recurrence_count"] < min_recurrence) {
+ continue;
+ }
+ let tids: any[];
+ try {
+ const parsed = JSON.parse(r["contributing_task_ids"]);
+ tids = Array.isArray(parsed) ? parsed : [];
+ } catch {
+ tids = [];
+ }
+ if (new Set(tids).size < min_tasks) {
+ continue;
+ }
+ if (
+ !TaskDB._within_window(r["first_seen"], r["last_seen"], window_days)
+ ) {
+ continue;
+ }
+ this.conn
+ .query(
+ "UPDATE skill_patterns SET status = 'candidate', updated_at = ? WHERE id = ?",
+ )
+ .run(now, r["id"]);
+ marked += 1;
+ }
+ return marked;
+ }
+
+ set_skill_pattern_status(
+ pattern_id: number,
+ status: string,
+ promoted_skill_id: number | null = null,
+ ): void {
+ this.conn
+ .query(
+ `
+ UPDATE skill_patterns
+ SET status = ?, promoted_skill_id = ?, updated_at = ?
+ WHERE id = ?
+ `,
+ )
+ .run(status, promoted_skill_id, nowIso(), pattern_id);
+ }
+
+ // ── Skill drafts ───────────────────────────────────────────────────────
+ upsert_skill_draft(
+ pattern_id: number,
+ status: string,
+ name: string = "",
+ description: string = "",
+ kind: string = "recipe",
+ body: string = "",
+ error: string | null = null,
+ worthy: boolean | null = null,
+ worthiness_reason: string = "",
+ ): void {
+ this.conn
+ .query(
+ `
+ INSERT INTO skill_drafts
+ (pattern_id, name, description, kind, body, status, error,
+ worthy, worthiness_reason, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT(pattern_id) DO UPDATE SET
+ name = excluded.name,
+ description = excluded.description,
+ kind = excluded.kind,
+ body = excluded.body,
+ status = excluded.status,
+ error = excluded.error,
+ worthy = excluded.worthy,
+ worthiness_reason = excluded.worthiness_reason,
+ updated_at = excluded.updated_at
+ `,
+ )
+ .run(
+ pattern_id,
+ name,
+ description,
+ kind,
+ body,
+ status,
+ error,
+ worthy === null ? null : worthy ? 1 : 0,
+ worthiness_reason,
+ nowIso(),
+ );
+ }
+
+ get_skill_draft(pattern_id: number): Row | null {
+ const row = this.conn
+ .query("SELECT * FROM skill_drafts WHERE pattern_id = ?")
+ .get(pattern_id) as Row | null;
+ return row ? { ...row } : null;
+ }
+
+ delete_skill_draft(pattern_id: number): void {
+ this.conn
+ .query("DELETE FROM skill_drafts WHERE pattern_id = ?")
+ .run(pattern_id);
+ }
+
+ // ── Skill registry ─────────────────────────────────────────────────────
+ // `path` shadows the node:path import inside this method (unused here).
+ add_skill(
+ name: string,
+ description: string,
+ path: string,
+ source_pattern_key: string | null = null,
+ source_task_ids: string | null = null,
+ kind: string | null = null,
+ ): number | null {
+ const cur = this.conn
+ .query(
+ `
+ INSERT INTO skills (name, description, path, source_pattern_key, source_task_ids, kind, enabled)
+ VALUES (?, ?, ?, ?, ?, ?, 1)
+ ON CONFLICT(name) DO UPDATE SET
+ description = excluded.description,
+ path = excluded.path,
+ source_pattern_key = excluded.source_pattern_key,
+ source_task_ids = excluded.source_task_ids,
+ kind = excluded.kind,
+ enabled = 1
+ `,
+ )
+ .run(name, description, path, source_pattern_key, source_task_ids, kind);
+ if (cur.lastInsertRowid) {
+ return Number(cur.lastInsertRowid);
+ }
+ const row = this.conn
+ .query("SELECT id FROM skills WHERE name = ?")
+ .get(name) as Row | null;
+ return row ? Number(row["id"]) : null;
+ }
+
+ get_skills(): Row[] {
+ const rows = this.conn
+ .query("SELECT * FROM skills ORDER BY created_at DESC")
+ .all() as Row[];
+ return rows.map((r) => ({ ...r }));
+ }
+
+ get_skill(skill_id: number): Row | null {
+ const row = this.conn
+ .query("SELECT * FROM skills WHERE id = ?")
+ .get(skill_id) as Row | null;
+ return row ? { ...row } : null;
+ }
+
+ set_skill_enabled(skill_id: number, enabled: boolean): void {
+ this.conn
+ .query("UPDATE skills SET enabled = ? WHERE id = ?")
+ .run(enabled ? 1 : 0, skill_id);
+ }
+
+ delete_skill(skill_id: number): void {
+ this.conn.query("DELETE FROM skills WHERE id = ?").run(skill_id);
+ }
+
+ add_dependency(
+ task_id: number,
+ depends_on_task_id: number,
+ inject_result: boolean = false,
+ ): void {
+ this.conn
+ .query(
+ `
+ INSERT OR IGNORE INTO task_dependencies (task_id, depends_on_task_id, inject_result)
+ VALUES (?, ?, ?)
+ `,
+ )
+ .run(task_id, depends_on_task_id, inject_result ? 1 : 0);
+ }
+
+ /**
+ * Insert multiple dependency rows for task_id in a single transaction.
+ *
+ * dep_list: list of objects with keys task_id (upstream) and inject_result.
+ * Rolls back all inserts if any one fails.
+ */
+ add_dependencies_batch(
+ task_id: number,
+ dep_list: Array<{ task_id: number; inject_result: unknown }>,
+ ): void {
+ this.transaction(() => {
+ for (const dep of dep_list) {
+ this.conn
+ .query(
+ `
+ INSERT OR IGNORE INTO task_dependencies (task_id, depends_on_task_id, inject_result)
+ VALUES (?, ?, ?)
+ `,
+ )
+ .run(task_id, dep.task_id, dep.inject_result ? 1 : 0);
+ }
+ });
+ }
+
+ remove_dependency(task_id: number, depends_on_task_id: number): void {
+ this.conn
+ .query(
+ `
+ DELETE FROM task_dependencies WHERE task_id = ? AND depends_on_task_id = ?
+ `,
+ )
+ .run(task_id, depends_on_task_id);
+ }
+
+ /** Remove all upstream dependencies for a task. */
+ clear_dependencies(task_id: number): void {
+ this.conn
+ .query("DELETE FROM task_dependencies WHERE task_id = ?")
+ .run(task_id);
+ }
+
+ /** Return upstream tasks that task_id depends on. */
+ get_dependencies(task_id: number): Row[] {
+ const rows = this.conn
+ .query(
+ `
+ SELECT td.*, t.title as depends_on_title, t.status as depends_on_status
+ FROM task_dependencies td
+ JOIN tasks t ON t.id = td.depends_on_task_id
+ WHERE td.task_id = ?
+ `,
+ )
+ .all(task_id) as Row[];
+ return rows.map((r) => ({ ...r }));
+ }
+
+ /** Return downstream tasks that depend on task_id. */
+ get_dependents(task_id: number): Row[] {
+ const rows = this.conn
+ .query(
+ `
+ SELECT td.*, t.title as task_title, t.status as task_status
+ FROM task_dependencies td
+ JOIN tasks t ON t.id = td.task_id
+ WHERE td.depends_on_task_id = ?
+ `,
+ )
+ .all(task_id) as Row[];
+ return rows.map((r) => ({ ...r }));
+ }
+
+ get_dag_tasks(dag_id: string): Row[] {
+ const rows = this.conn
+ .query("SELECT * FROM tasks WHERE dag_id = ? ORDER BY created_at ASC")
+ .all(dag_id) as Row[];
+ return rows.map((r) => this._deserialize_task(r));
+ }
+
+ delete_task(task_id: number): void {
+ this.transaction(() => {
+ this.conn
+ .query("DELETE FROM task_output_events WHERE task_id = ?")
+ .run(task_id);
+ this.conn.query("DELETE FROM task_runs WHERE task_id = ?").run(task_id);
+ this.conn
+ .query(
+ "DELETE FROM task_dependencies WHERE task_id = ? OR depends_on_task_id = ?",
+ )
+ .run(task_id, task_id);
+ this.conn.query("DELETE FROM tasks WHERE id = ?").run(task_id);
+ });
+ }
+}
diff --git a/backend/src/executor.ts b/backend/src/executor.ts
new file mode 100644
index 0000000..db896bc
--- /dev/null
+++ b/backend/src/executor.ts
@@ -0,0 +1,283 @@
+// AgentExecutor + subprocess seams, ported from taskboard.py (class
+// AgentExecutor, lines 1597-1656).
+//
+// Python runs agent CLIs through the `subprocess` module and the pytest
+// suites monkeypatch `taskboard.subprocess.run` / `taskboard.subprocess.Popen`
+// (plus `taskboard.os.getpgid` / `taskboard.os.killpg`). The TS port keeps
+// the same seams injectable so the ported tests can swap in fakes:
+//
+// - `AgentExecutor.subprocess_run` (≙ subprocess.run) — swappable static
+// - `TaskScheduler._popen` (≙ subprocess.Popen) — swappable property
+// - `TaskScheduler._os` (≙ os.getpgid/killpg/kill)
+//
+// This module also defines the exception types (FileNotFoundError /
+// TimeoutExpired / OSError / ProcessLookupError) and the PIPE sentinel that
+// Python code references through the subprocess/os builtins.
+
+import { spawn, spawnSync } from "node:child_process";
+import type { Readable } from "node:stream";
+import { CLAUDE_STREAM_JSON_ARGS, DEFAULT_TIMEOUT_SECONDS } from "./types.ts";
+import { getEnv } from "./util.ts";
+import { expanduser } from "./skills.ts";
+
+// ── exception types (≙ Python builtins / subprocess) ────────────────────────
+
+/** ≙ builtins.OSError */
+export class OSError extends Error {}
+
+/** ≙ builtins.FileNotFoundError (subclass of OSError, like Python) */
+export class FileNotFoundError extends OSError {}
+
+/** ≙ builtins.ProcessLookupError (subclass of OSError, like Python) */
+export class ProcessLookupError extends OSError {}
+
+/** ≙ subprocess.TimeoutExpired */
+export class TimeoutExpired extends Error {
+ cmd: unknown;
+ timeout: number | null;
+
+ constructor(cmd: unknown = null, timeout: number | null = null) {
+ super(`Command timed out after ${timeout} seconds`);
+ this.cmd = cmd;
+ this.timeout = timeout;
+ }
+}
+
+/** ≙ subprocess.PIPE sentinel (tests assert `captured.stdin === PIPE`). */
+export const PIPE: unique symbol = Symbol.for("subprocess.PIPE");
+
+// ── subprocess.run seam (used by AgentExecutor.run) ─────────────────────────
+
+export interface SubprocessRunResult {
+ returncode: number;
+ stdout: string;
+ stderr: string;
+}
+
+export interface SubprocessRunOptions {
+ cwd: string;
+ timeout: number; // seconds (≙ subprocess.run(timeout=...))
+ env: Record;
+}
+
+export type SubprocessRunFn = (
+ cmd: string[],
+ opts: SubprocessRunOptions,
+) => SubprocessRunResult;
+
+/** Real implementation backed by child_process.spawnSync. */
+export function default_subprocess_run(
+ cmd: string[],
+ opts: SubprocessRunOptions,
+): SubprocessRunResult {
+ const res = spawnSync(cmd[0]!, cmd.slice(1), {
+ cwd: opts.cwd,
+ env: opts.env,
+ timeout: opts.timeout * 1000,
+ encoding: "utf-8",
+ maxBuffer: 64 * 1024 * 1024,
+ });
+ if (res.error) {
+ const code = (res.error as NodeJS.ErrnoException).code;
+ if (code === "ENOENT") throw new FileNotFoundError(res.error.message);
+ if (code === "ETIMEDOUT") throw new TimeoutExpired(cmd, opts.timeout);
+ throw new OSError(res.error.message);
+ }
+ // Killed by the timeout without spawnSync reporting ETIMEDOUT (signal exit).
+ if (res.signal === "SIGTERM" && res.status === null) {
+ throw new TimeoutExpired(cmd, opts.timeout);
+ }
+ return {
+ returncode: res.status ?? -1,
+ stdout: res.stdout ?? "",
+ stderr: res.stderr ?? "",
+ };
+}
+
+// ── subprocess.Popen seam (used by TaskScheduler) ───────────────────────────
+
+export interface PopenStdin {
+ write(data: string): void;
+ close(): void;
+}
+
+/**
+ * Minimal structural view of a Popen object as taskboard.py uses it:
+ * iterate stdout/stderr line-by-line, wait(), kill(), read returncode.
+ * Test fakes implement exactly this shape.
+ */
+export interface PopenLike {
+ pid: number;
+ stdout: Iterable | AsyncIterable