内容
# Airlock
**在一个仓库中运行五个代理而不会相互冲突。**
命名资源锁、原子共享状态、存在、事件和任务队列,供AI代理使用 — 通过本地HTTP MCP服务器公开,该服务器始终运行,因此任何会话、终端或CI作业都可以与其他作业协调。 跨平台Go守护进程,SQLite支持,具有真正的**阻塞等待** — 不再需要轮询循环。





*两个代理争夺同一个`npm-install`锁 — 一个赢得,另一个在服务器端等待并在第一个释放时立即获取。*
<!-- Static fallback if the GIF doesn't render: -->
```
Agent A Agent B
────── ──────
lock_resource("npm-install") lock_resource("npm-install", wait_seconds=50)
→ { locked: true, lock_token } (parked — the daemon holds the connection open)
npm install ...
unlock_resource(lock_token)
→ { locked: true, lock_token } ← wakes instantly
npm install ...
```
## 问题
打开同一个仓库的两个Claude Code会话 — 一个添加身份验证,另一个添加通知。两者同时访问`npm install`。两者都选择`003_`作为下一个迁移文件名。彼此都不知道对方的存在。
没有共享状态。没有交接。没有一种方法可以让一个代理知道另一个代理正在做什么。协调存在于脆弱的提示指令(“请等待,直到迁移完成”)中,没有任何强制执行。
## 工作原理
Airlock是一个单独的Go守护进程,作为后台服务运行 — 始终在线,生存于IDE和终端重启。它监听`127.0.0.1:27183`并通过HTTP使用MCP协议进行通信。任何配置了该守护进程的代理都可以:
- **获取锁** — 并在争用锁时**阻塞** (`wait_seconds`,最多50秒),因此守护进程会暂停调用者,并在锁释放时唤醒它。无需代理编写重试循环。
- **共享原子状态** — `increment_counter`用于无冲突的唯一数字,`set_note_if`用于比较和交换。读取-修改-写入是无竞争的,而不是仅限于尽力而为。
- **宣布存在** — `register_agent`心跳;当代理死亡时,其锁会释放,等待者会立即唤醒。
- **信号事件** — 生成计数`signal_event` / `wait_for_event`用于交接,无需轮询。
- **队列工作** — 一个小型任务队列,具有基于存在的租约,如果崩溃会自动重新排队。
状态存储在**SQLite(WAL模式)**中,位于`~/.airlock/state.db` — 事务性、崩溃安全,具有并发读取器和单个写入器。存储纯粹使用Go(`modernc.org/sqlite`,无cgo),这使得Linux构建成为一个微不足道的跨编译。一个二进制文件,无需语言运行时,无需外部数据库。
## 安装
### (a) `go install`
```bash
go install github.com/adamorad/airlock/v2@latest # 构建`airlock`二进制文件
airlock install-service # launchd(macOS)/ systemd用户单元(Linux)
```
### (b) Homebrew
```bash
brew install adamorad/tap/airlock
airlock install-service
```
### (c) 从源代码构建
```bash
git clone https://github.com/adamorad/airlock.git
cd airlock
go build -o airlock .
sudo install airlock /usr/local/bin/airlock # 或任何PATH中的位置
airlock install-service
```
`install-service`注册一个始终运行的后台服务(并卸载旧v1 LaunchAgent,如果存在,则v2守护进程会接管端口27183)。
### 验证
```bash
airlock status
# Airlock — 127.0.0.1:27183
#
# LOCKS (0)
# (无)
# ...
```
### 配置您的代理
**Claude Code** — 一个命令:
```bash
claude mcp add --transport http airlock http://localhost:27183
```
**Cursor / Windsurf / 任意MCP客户端** — 将以下内容添加到您的MCP配置中:
```json
{
"mcpServers": {
"airlock": {
"type": "http",
"url": "http://127.0.0.1:27183"
}
}
}
```
**在Linux**(和任何多用户主机)上,需要**Bearer Token** — 环回在所有用户之间共享,因此它本身不是授权边界。守护进程在第一次运行时将`0600`令牌写入`~/.airlock/token`;请将其作为`Authorization: Bearer <token>`头部发送:
```json
{
"mcpServers": {
"airlock": {
"type": "http",
"url": "http://127.0.0.1:27183",
"headers": { "Authorization": "Bearer <paste contents of ~/.airlock/token>" }
}
}
}
```
在**macOS**上,守护进程默认仅限于环回,并且令牌是可选的。`AIRLOCK_TOKEN`覆盖任何操作系统上的文件。
## 工具
Airlock通过MCP公开**22种工具**。每个工具的结果都是一个JSON对象(`list_*`工具返回一个JSON数组)。TTL以`ttl_seconds`表示;`ttl_minutes`作为已弃用的别名被接受。
### 锁
| 工具 | 参数 | 返回值 |
|------|------|---------|
| `lock_resource` | `name`, `agent_id`, `ttl_seconds?`=900, `wait_seconds?`=0 (上限50), `wake_token?` | `{locked:true, lock_token, expires_in_seconds}` — 或,如果有竞争, `{locked:false, held_by, expires_in_seconds}` 加上 `{wake_token, queue_position, retry_with}` 当您排队时 |
| `unlock_resource` | `name`, `lock_token`(优先)或 `agent_id` | `{released: bool}` |
| `renew_lock` | `name`, `lock_token`(优先)或 `agent_id`, `ttl_seconds?`=900 | `{renewed:true, expires_in_seconds}` 或 `{renewed:false, error}` |
| `list_locks` | — | `[{name, agent_id, expires_in_seconds}]` |
| `lock_resources` | `names:[string]`, `agent_id`, `ttl_seconds?`=900 | 全部或无: `{locked:true, tokens:{name:token}}` 或 `{locked:false, held_by}` |
`wait_seconds`是默认协调的旋钮:传递它(最多**50**)并且 `lock_resource` **阻塞**服务器端,直到锁释放,而不是立即返回 `locked:false`。上限保持在Claude Code的60秒默认每个工具调用超时之下。返回的 **`lock_token` 是一个能力** — `unlock_resource`/`renew_lock` 需要它(`agent_id`路径保留用于v1兼容性)。 `lock_resources`按文档化的锁顺序(按名称字典排序)获取,因此两个调用者不会死锁。
### 注释和状态
| 工具 | 参数 | 返回值 |
|------|------|---------|
| `set_note` | `key`, `value`, `author?`, `ttl_seconds?` | `{saved:true}` |
| `get_note` | `key` | `{key, value, author?, expires_in_seconds?}` 或 `{found:false}` |
| `list_notes` | — | `[{key, value, author?, expires_in_seconds?}]` |
| `delete_note` *(v2)* | `key` | `{deleted: bool}` |
| `set_note_if` *(v2)* | `key`, `expected_value`, `new_value`, `author?`, `ttl_seconds?` | `{swapped: bool}` (仅当存储的值等于`expected_value`时为真;不存在/过期的注释计为`" "`) |
| `increment_counter` *(v2)* | `name`, `by?`=1 | `{value}` (后递增;并发下无冲突) |
### 存在
| 工具 | 参数 | 返回值 |
|------|------|---------|
| `register_agent` *(v2)* | `agent_id`, `ttl_seconds?`=60 | `{registered:true, expires_in_seconds}` — 重新调用以保持活跃;当它过期时,代理的锁会自动释放 |
| `unregister_agent` *(v2)* | `agent_id` | `{unregistered:true}` — 同时释放持有的锁 |
| `list_agents` *(v2)* | — | `[{agent_id, expires_in_seconds}]` |
### 事件
| 工具 | 参数 | 返回值 |
|------|------|---------|
| `signal_event` *(v2)* | `name` | `{generation}` (新的、增加的generation;唤醒所有等待者) |
| `wait_for_event` *(v2)* | `name`, `last_seen_generation?`=0, `wait_seconds?`=25 (上限50) | `{generation, fired}` — 阻塞直到generation超过`last_seen_generation`,或窗口过期(`fired:false`) |
| `clear_event` *(v2)* | `name` | `{cleared:true}` |
事件是**生成计数**的,而不是锁定的:传回您最后看到的generation,并且在调用之间触发的信号永远不会丢失。
### 任务
| 工具 | 参数 | 返回值 |
|------|------|---------|
| `push_task` *(v2)* | `queue`, `payload`, `author?`, `priority?`=0 | `{id}` (优先级/年龄较高的优先; older claimed first) |
| `claim_next_task` *(v2)* | `queue`, `agent_id`, `lease_seconds?`=120 | `{claimed:true, id, payload, lease_token}` 或 `{claimed:false}` |
| `complete_task` *(v2)* | `id`, `lease_token` | `{completed: bool}` |
| `fail_task` *(v2)* | `id`, `lease_token`, `requeue?`=true | `{failed: bool}` (requeue=true 返回到pending;false放弃) |
| `list_tasks` *(v2)* | `queue` | `[{id, queue, payload, priority, state, author?, lease_agent?, lease_expires_in_seconds?}]` |
声明是一种**租约**:如果声明者不`complete_task`/`fail_task`在`lease_seconds`内完成,任务将自动重新排队 — 不会因工作进程崩溃而丢失任何工作。来自`claim_next_task`的`lease_token`是`complete_task`/`fail_task`需要的能力。
### 命名约定
使用一致的名称,以便代理之间可以理解彼此:
- **文件:** `file:/abs/path/to/package.json`
- **进程:** `npm-install`, `db-migrations`, `tests:unit`
- **代理身份:** `agent:claude-session-abc`
## 食谱
### (a) 序列化编辑一个文件并使用阻塞锁
两个子代理都需要编辑`package.json`。它们序列化一个锁 — 第二个代理会**阻塞**(无需重试循环)并在第一个代理释放时立即唤醒。
```bash
# 子代理A — 阻塞最多50秒获取,编辑,释放。
A=$(curl -s -X POST localhost:27183 -H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"lock_resource","arguments":{"name":"file:/repo/package.json","agent_id":"sub-A","wait_seconds":50}}}' \
| jq -r '.result.content[0].text | fromjson | .lock_token')
# ...编辑文件...
curl -s -X POST localhost:27183 -H 'Content-Type: application/json' \
-d "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"unlock_resource\",\"arguments\":{\"name\":\"file:/repo/package.json\",\"lock_token\":\"$A\"}}}" \
| jq -c '.result.content[0].text | fromjson' # → {"released":true}
# 子代理B — 相同的调用,wait_seconds=50 停车,直到A释放,然后
# 返回 {locked:true, lock_token} 锁释放的那一刻。无需轮询。
```
### (b) 唯一迁移文件名 — 无竞争
经典的v1危险:两个代理同时`get_note`计数器,两个读取`003`,两个写入`004`。在v2中,没有读取-修改-写入丢失 — `increment_counter`是原子的,因此100个并发调用者获得100个不同的数字。
```bash
curl -s -X POST localhost:27183 -H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"increment_counter","arguments":{"name":"migration-seq"}}}' \
| jq -c '.result.content[0].text | fromjson' # → {"value":4}
# 使用返回的值:创建0004_*.sql。下一个调用者得到5,从不冲突。
```
### (c) 通过任务队列的生产者/消费者交接
规划器代理推送工作;一个或多个工作代理声明它。租约意味着崩溃的工作代理的任务会自动重新排队,而不是消失。
```bash
# 生产者 — 排队一个工作单元。
curl -s -X POST localhost:27183 -H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"push_task","arguments":{"queue":"build","payload":"compile module X","author":"planner"}}}' \
| jq -c '.result.content[0].text | fromjson' → {"id":1}
# 消费者 — 声明下一个任务,做工作,完成它并带有租赁令牌。
CLAIM=$(curl -s -X POST localhost:27183 -H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"claim_next_task","arguments":{"queue":"build","agent_id":"worker-1"}}}' \
| jq -c '.result.content[0].text | fromjson') → {"claimed":true,"id":1,"payload":"compile module X","lease_token":"..."}
ID=$(echo "$CLAIM" | jq -r .id); TOK=$(echo "$CLAIM" | jq -r .lease_token)
# ...做工作...
curl -s -X POST localhost:27183 -H 'Content-Type: application/json' \
-d "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\",\"params\":{\"name\":\"complete_task\",\"arguments\":{\"id\":$ID,\"lease_token\":\"$TOK\"}}}" \
| jq -c '.result.content[0].text | fromjson' → {"completed":true}
```
## `airlock status`
一次性快照守护进程正在协调的所有内容 — 锁(以及谁持有它们),存在代理,注释,计数器,事件和任务。运行`~/.airlock/state.db`,因此即使从不同的shell也可以工作。`airlock watch` 每秒重新打印一次。
```
Airlock — 127.0.0.1:27183
LOCKS (2)
NAME HELD BY EXPIRES IN
file:/repo/package.json sub-A 4m30s
npm-install session-A 12m
AGENTS (2)
AGENT EXPIRES IN
session-A 48s
worker-1 59s
NOTES (1)
KEY VALUE AUTHOR EXPIRES IN
deploy-status building planner —
COUNTERS (1)
NAME VALUE
migration-seq 4
EVENTS (1)
NAME GENERATION
migrations-done 2
TASKS (1)
1 QUEUE STATE PAYLOAD LEASE AGENT
2 build claimed compile module Y worker-1
```
## 架构
单个Go守护进程,一个进程,在`127.0.0.1:27183`。
- **存储。** SQLite在**WAL模式**下的`~/.airlock/state.db` — 纯粹的Go(`modernc.org/sqlite`,无cgo),事务性,崩溃安全。单个专用写入连接序列化写入;读取器使用WAL的并发路径。SQLite的单个写入器模型使`increment_counter`和`set_note_if`免费获得正确性。
- **阻塞等待。** 停靠的等待者是一个goroutine在一个资源相关的**内存FIFO通道**上,并带有`context`截止日期 — *在*任何SQLite事务之外,因此阻塞守护进程永远不会融化数据库下的争论。后台**回收者**扫描过期的锁TTL和存在租约,并唤醒FIFO头部,以便死锁后的等待者不会吃掉他们的整个等待窗口。
- **能力令牌。** `lock_token` / `lease_token`控制`unlock`/`renew`和`complete`/`fail`。`agent_id`是出处,而不是授权。
- **安全。** 环回绑定,`Host`头部允许列表,拒绝任何`Origin`头部(DNS重绑定的防御),强制`application/json`进行POST — 加上Linux /多用户主机上的`0600`令牌。参见[SECURITY.md](SECURITY.md)。
- **端口27183。** [PortPeek](https://adamorad.github.io/portpeek)拥有`27182`(*e*的数字);Airlock使用下一个端口,以便两个守护进程在单个机器上共存而不会发生冲突。
v1(Swift,macOS-only)存档在`legacy/swift/`并标记为`v1.1.0`。完整的v1线路合约仍然适用于v2 — 相同的端口,相同的JSON。
## 路线图 / v2状态
**v2.0.0发布核心** — 锁与阻塞等待,原子状态,存在和事件 — **加上任务队列**,所有这些都使用Go且跨平台(macOS + Linux)。设计规格和路线图,包括Airlock如何与相邻工具交互,位于[docs/ROADMAP-v2.md](docs/ROADMAP-v2.md)。如果路线图与您的工作流程不符,[打开一个问题](https://github.com/adamorad/airlock/issues) — 有一个固定的“拆卸”线程。
## 卸载
```bash
airlock uninstall-service # 删除launchd / systemd服务
brew uninstall airlock # 如果通过tap安装:brew uninstall adamorad/tap/airlock
rm -rf ~/.airlock # 可选:删除状态数据库+令牌
```
## 常见问题
**为什么使用端口27183?** PortPeek已经使用`27182`(*e*的前两位数字)。Airlock使用下一个端口`27183`,因此两个守护进程可以在一台机器上共存而不会发生冲突。
**安全模型是什么?** Airlock绑定仅限于环回,验证`Host`头部,拒绝任何`Origin`头部,并强制POST使用`application/json`。在Linux /多用户主机上,`0600`令牌在`~/.airlock/token`是**必需的**(环回在用户之间共享);在macOS上它是可选的。能力令牌(`lock_token` / `lease_token`)意味着一个有bug或提示注入的代理无法通过猜测其`agent_id`来丢弃或窃取另一个代理的锁。刻意的文件/任务内容限制:消耗代理必须将跨代理输入视为数据,而不仅仅是指令。完整的威胁模型:[SECURITY.md](SECURITY.md)。
**它与PortPeek有什么关系?** [PortPeek](https://adamorad.github.io/portpeek)解决了AI代理的*端口*冲突(谁绑定到哪个端口)。Airlock解决了更广泛的*协调*问题(谁正在执行哪个任务)。相同的血统,邻近的端口(`27182` / `27183`),设计为一起运行。
## 贡献
问题和PR欢迎 — 请参阅[CONTRIBUTING.md](CONTRIBUTING.md)。
在[MIT License](LICENSE)许可下。
由[Adam Morad](https://il.linkedin.com/in/adam-morad)构建。
MCP 配置
以下是此 MCP Server 的配置信息,可直接复制到 Cursor 或其他 MCP 客户端中使用。
mcp.json
连接信息
猜你喜欢
暂无推荐服务器