Skip to content

Commit 693831a

Browse files
CodeCasterXcodexclaude
committed
feat(server): add IM command protocol
- Route Feishu messages through command parsing, auth, runner, and streaming. - Add ai run and ai decide lifecycle commands for controlled remote workflows. - Capture sandbox output and serialize streamed replies before completion. - Document command routing, permissions, and TUI selection. Co-Authored-By: Codex <noreply@openai.com> Co-Authored-By: Claude <noreply@anthropic.com>
1 parent a27a70d commit 693831a

28 files changed

Lines changed: 1282 additions & 20 deletions

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ Every command above works the same way in Claude Code, Codex, Gemini CLI, and Op
8282

8383
- **Multi-AI collaboration**: one shared operating model for Claude Code, Codex, Gemini CLI, and OpenCode
8484
- **Bootstrap CLI + skill-driven execution**: initialize once, then let AI skills drive day-to-day work
85+
- **IM command bridge**: route Feishu messages to built-ins, read-only task views, sandbox management, and controlled lifecycle skill runs
8586
- **Bilingual project docs**: English-first docs with synchronized Chinese translations
8687
- **Template-source architecture**: `templates/` mirrors the rendered project structure
8788
- **AI-assisted updates**: template changes can be merged while preserving project-specific customization
@@ -191,7 +192,7 @@ In-depth guides live under [`docs/en/`](./docs/en/README.md):
191192
- [Architecture Overview](./docs/en/architecture.md) — bootstrap CLI, end-to-end flow, layered architecture
192193
- [Platform Support](./docs/en/platform-support.md) — macOS, Linux, Windows; sandbox engines and resources
193194
- [Sandbox](./docs/en/sandbox.md) — sandbox aliases, host-sandbox file exchange, user-level dotfiles channel
194-
- [Feishu Bridge](./docs/en/feishu-bridge.md) — configure the Feishu long-connection adapter and `/ping` verification
195+
- [Feishu Bridge](./docs/en/feishu-bridge.md) — configure the Feishu long-connection adapter, command routing, permissions, and TUI selection
195196
- [Built-in AI Skills](./docs/en/skills.md) — the full skill catalog by use case
196197
- [Custom Skills](./docs/en/custom-skills.md) — create and sync project-specific skills
197198
- [Custom TUI Configuration](./docs/en/custom-tui.md) — adapt agent-infra to non-built-in AI TUIs

README.zh-CN.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ agent-infra 的目标就是把这层共享基础设施标准化。它为所有
8282

8383
- **多 AI 协作**:为 Claude Code、Codex、Gemini CLI、OpenCode 提供统一的协作模型
8484
- **引导 CLI + skill 驱动执行**:初始化一次,后续日常操作交给 AI skills
85+
- **IM 命令桥接**:把飞书消息路由到内置命令、任务只读视图、沙箱管理和受控生命周期 skill 执行
8586
- **双语文档**:英文为主文档,配套同步的中文版本
8687
- **模板源架构**`templates/` 目录镜像最终渲染出的项目结构
8788
- **AI 辅助升级**:模板升级时可合并变更,同时尽量保留项目侧定制
@@ -191,7 +192,7 @@ my-project/
191192
- [架构概览](./docs/zh-CN/architecture.md) — 引导 CLI、端到端流程、分层架构
192193
- [平台支持](./docs/zh-CN/platform-support.md) — macOS、Linux、Windows;沙箱引擎与资源配置
193194
- [沙箱](./docs/zh-CN/sandbox.md) — 沙箱 aliases、宿主-沙箱文件交换、用户级 dotfiles 通道
194-
- [飞书桥接](./docs/zh-CN/feishu-bridge.md) — 配置飞书长连接 adapter 并验证 `/ping`
195+
- [飞书桥接](./docs/zh-CN/feishu-bridge.md) — 配置飞书长连接 adapter、命令路由、权限和 TUI 选择
195196
- [内置 AI Skills](./docs/zh-CN/skills.md) — 按使用场景分组的完整 skill 清单
196197
- [自定义 Skills](./docs/zh-CN/custom-skills.md) — 创建并同步项目专属 skill
197198
- [自定义 TUI 配置](./docs/zh-CN/custom-tui.md) — 适配非内置的 AI TUI

bin/cli.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ Commands:
2121
merge Merge tasks from another workspace directory (active/blocked/completed/archive)
2222
sandbox, s Manage Docker-based AI sandboxes
2323
server Run the local AI collaboration daemon (start/stop/status/logs)
24+
run Run a whitelisted lifecycle skill through a non-interactive TUI
25+
decide Record a human decision for a task HD item
2426
task, t Read-only views over .agents/workspace tasks (cat / files / grep / log / ls / show / status)
2527
update Update seed files and sync file registry for an existing project
2628
version Show version
@@ -119,6 +121,20 @@ switch (command) {
119121
});
120122
break;
121123
}
124+
case 'run': {
125+
const imported = await importCommand('../lib/run/index.ts');
126+
if (!imported) break;
127+
const { cmdRun } = imported;
128+
await cmdRun(process.argv.slice(3));
129+
break;
130+
}
131+
case 'decide': {
132+
const imported = await importCommand('../lib/decide.ts');
133+
if (!imported) break;
134+
const { cmdDecide } = imported;
135+
await cmdDecide(process.argv.slice(3));
136+
break;
137+
}
122138
case 'task': {
123139
const imported = await importCommand('../lib/task/index.ts');
124140
if (!imported) break;

docs/en/feishu-bridge.md

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
[← Documentation](./README.md) · [中文](../zh-CN/feishu-bridge.md)
44

5-
The local `ai server` daemon can host IM adapters. The Feishu adapter connects to the Feishu Open Platform over the official SDK long connection and routes received messages to the built-in command dispatcher. The current minimal command is `/ping`, which replies `pong v<VERSION>`.
5+
The local `ai server` daemon can host IM adapters. The Feishu adapter connects to the Feishu Open Platform over the official SDK long connection and routes received messages to the command dispatcher. Built-ins such as `/ping`, `/help`, and `/version` run in the daemon; task and sandbox commands are routed through the local `ai` CLI.
66

77
## Create the App
88

@@ -20,7 +20,7 @@ Use the narrowest set that matches the chat types you want to test:
2020
| Group chat `@bot /ping` | `im.message.group_at_msg:readonly`, `im:message:send_as_bot` |
2121
| Both direct and group chat | `im.message.p2p_msg:readonly`, `im.message.group_at_msg:readonly`, `im:message:send_as_bot` |
2222

23-
Some Feishu consoles may also show or auto-enable broader parent permissions such as `im:message`. The current adapter does not need chat metadata (`im:chat`) or message reaction permissions for `/ping`.
23+
Some Feishu consoles may also show or auto-enable broader parent permissions such as `im:message`. The adapter does not need chat metadata (`im:chat`) or message reaction permissions for command routing.
2424

2525
After changing permissions or event subscriptions, publish the app version and make sure the app is installed in the tenant before testing.
2626

@@ -30,6 +30,18 @@ Put the app credentials in `.agents/server.local.json`. This file is git-ignored
3030

3131
```json
3232
{
33+
"command": {
34+
"defaultTui": "codex",
35+
"skillTuiDefaults": {
36+
"code-task": "codex",
37+
"review-code": "claude"
38+
}
39+
},
40+
"auth": {
41+
"users": {
42+
"feishu:ou_xxx": { "role": "exec", "name": "maintainer" }
43+
}
44+
},
3345
"adapters": {
3446
"feishu": {
3547
"enabled": true,
@@ -42,6 +54,35 @@ Put the app credentials in `.agents/server.local.json`. This file is git-ignored
4254

4355
`appId` must match `cli_[0-9a-fA-F]{16}`. The daemon fails fast if `appId` or `appSecret` is missing.
4456

57+
## Commands
58+
59+
| Command | Role | Execution |
60+
|---------|------|-----------|
61+
| `/ping`, `/help`, `/version` | public built-in | daemon |
62+
| `/task ls`, `/task status <ref>`, `/task show <ref>`, `/task log <ref>`, `/task decisions <ref>` | `read` | `ai task ...` |
63+
| `/sandbox ls`, `/sandbox show <ref>`, `/sandbox vm status` | `read` | `ai sandbox ...` |
64+
| `/sandbox create <ref>`, `/sandbox start <ref>` | `write` | `ai sandbox ...` |
65+
| `/run create-task <description> [--tui <name>]` | `write` | `ai run create-task ...` on the host |
66+
| `/run <skill> <task-ref> [args...] [--tui <name>]` | `exec` | `ai run ...`; task skills run in the matching sandbox |
67+
| `/decide <task-ref> <HD-id> <decision>` | `write` | `ai decide ...` |
68+
69+
`/task` commands are read-only views. Lifecycle progress goes through `/run`. Task skills resolve `<task-ref>` to the task branch, find the matching sandbox, and fail with an instruction to run `ai sandbox create <task-ref>` if no sandbox exists. `create-task` is the only v1 skill runner that does not require an existing task or sandbox. Sandbox removal is intentionally not exposed through IM because local deletion still requires interactive confirmation.
70+
71+
## TUI Selection
72+
73+
`ai run` chooses the non-interactive TUI in this order:
74+
75+
1. `--tui <name>` on the command
76+
2. `command.skillTuiDefaults[skill]`
77+
3. `command.defaultTui`
78+
4. built-in default `codex`
79+
80+
Supported built-ins are `claude`, `codex`, `gemini`, and `opencode`. The generated prompts are `/skill ...` for Claude Code/OpenCode, `$skill ...` for Codex, and `/agent-infra:skill ...` for Gemini CLI.
81+
82+
## Authorization
83+
84+
Non-built-in commands are fail-closed. Add adapter-qualified users under `auth.users` and assign one of `read`, `write`, or `exec`; higher roles include lower roles. Unknown users cannot run `/task`, `/sandbox`, `/run`, or `/decide`. Built-ins remain available for connectivity checks.
85+
4586
## References
4687

4788
- [Feishu long connection event subscription](https://open.feishu.cn/document/server-docs/event-subscription-guide/event-subscription-configure-/request-url-configuration-case?lang=en-US)

docs/zh-CN/feishu-bridge.md

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
[← 文档](./README.md) · [English](../en/feishu-bridge.md)
44

5-
本地 `ai server` 守护进程可托管 IM adapter。飞书 adapter 通过官方 SDK 长连接接入飞书开放平台,并把收到的消息路由到内置命令分发器。当前最小命令是 `/ping`,回复 `pong v<VERSION>`
5+
本地 `ai server` 守护进程可托管 IM adapter。飞书 adapter 通过官方 SDK 长连接接入飞书开放平台,并把收到的消息路由到命令分发器。`/ping``/help``/version` 等内置命令在 daemon 内执行;任务与沙箱命令通过本机 `ai` CLI 执行
66

77
## 创建应用
88

@@ -20,7 +20,7 @@
2020
| 群聊 `@机器人 /ping` | `im.message.group_at_msg:readonly``im:message:send_as_bot` |
2121
| 同时支持单聊和群聊 | `im.message.p2p_msg:readonly``im.message.group_at_msg:readonly``im:message:send_as_bot` |
2222

23-
部分飞书控制台可能会展示或自动开通更宽的父级权限,例如 `im:message`当前 `/ping` adapter 不需要群信息权限 `im:chat`,也不需要消息表情回复权限。
23+
部分飞书控制台可能会展示或自动开通更宽的父级权限,例如 `im:message`命令路由不需要群信息权限 `im:chat`,也不需要消息表情回复权限。
2424

2525
修改权限或事件订阅后,需要发布应用版本,并确认应用已安装到当前企业,之后再测试。
2626

@@ -30,6 +30,18 @@
3030

3131
```json
3232
{
33+
"command": {
34+
"defaultTui": "codex",
35+
"skillTuiDefaults": {
36+
"code-task": "codex",
37+
"review-code": "claude"
38+
}
39+
},
40+
"auth": {
41+
"users": {
42+
"feishu:ou_xxx": { "role": "exec", "name": "maintainer" }
43+
}
44+
},
3345
"adapters": {
3446
"feishu": {
3547
"enabled": true,
@@ -42,6 +54,35 @@
4254

4355
`appId` 必须符合 `cli_[0-9a-fA-F]{16}`。如果缺少 `appId``appSecret`,daemon 会 fail fast。
4456

57+
## 命令
58+
59+
| 命令 | 角色 | 执行路径 |
60+
|------|------|----------|
61+
| `/ping``/help``/version` | 公开内置 | daemon |
62+
| `/task ls``/task status <ref>``/task show <ref>``/task log <ref>``/task decisions <ref>` | `read` | `ai task ...` |
63+
| `/sandbox ls``/sandbox show <ref>``/sandbox vm status` | `read` | `ai sandbox ...` |
64+
| `/sandbox create <ref>``/sandbox start <ref>` | `write` | `ai sandbox ...` |
65+
| `/run create-task <描述> [--tui <name>]` | `write` | 在宿主环境执行 `ai run create-task ...` |
66+
| `/run <skill> <task-ref> [args...] [--tui <name>]` | `exec` | `ai run ...`;任务态 skill 在匹配沙箱中执行 |
67+
| `/decide <task-ref> <HD-id> <裁定>` | `write` | `ai decide ...` |
68+
69+
`/task` 命令只提供只读视图。任务推进统一走 `/run`。任务态 skill 会把 `<task-ref>` 解析到任务分支,再查找对应 sandbox;如果 sandbox 不存在,会提示先运行 `ai sandbox create <task-ref>``create-task` 是 v1 中唯一不要求已有任务和 sandbox 的 skill runner。沙箱删除仍需要本地交互确认,因此不通过 IM 暴露。
70+
71+
## TUI 选择
72+
73+
`ai run` 按以下顺序选择非交互 TUI:
74+
75+
1. 命令中的 `--tui <name>`
76+
2. `command.skillTuiDefaults[skill]`
77+
3. `command.defaultTui`
78+
4. 内置默认 `codex`
79+
80+
内置支持 `claude``codex``gemini``opencode`。生成的 prompt 分别为 Claude Code/OpenCode 的 `/skill ...`、Codex 的 `$skill ...`、Gemini CLI 的 `/agent-infra:skill ...`
81+
82+
## 授权
83+
84+
非内置命令默认 fail-closed。把 adapter-qualified 用户写入 `auth.users`,角色为 `read``write``exec`;高角色包含低角色权限。未知用户不能执行 `/task``/sandbox``/run``/decide`。内置命令保留给连通性检查。
85+
4586
## 参考
4687

4788
- [飞书长连接事件订阅](https://open.feishu.cn/document/server-docs/event-subscription-guide/event-subscription-configure-/request-url-configuration-case?lang=zh-CN)

lib/decide.ts

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import fs from 'node:fs';
2+
import path from 'node:path';
3+
import { execFileSync } from 'node:child_process';
4+
import { VERSION } from './version.ts';
5+
import { resolveTaskRef } from './task/resolve-ref.ts';
6+
7+
const TASK_ID_RE = /^TASK-\d{8}-\d{6}$/;
8+
9+
type DecideOptions = {
10+
repoRoot?: string;
11+
now?: () => string;
12+
version?: string;
13+
};
14+
15+
function detectRepoRoot(): string {
16+
return execFileSync('git', ['rev-parse', '--show-toplevel'], {
17+
encoding: 'utf8',
18+
stdio: ['pipe', 'pipe', 'pipe']
19+
}).trim();
20+
}
21+
22+
function defaultNow(): string {
23+
return new Intl.DateTimeFormat('sv-SE', {
24+
timeZoneName: 'longOffset',
25+
year: 'numeric',
26+
month: '2-digit',
27+
day: '2-digit',
28+
hour: '2-digit',
29+
minute: '2-digit',
30+
second: '2-digit',
31+
hour12: false
32+
})
33+
.format(new Date())
34+
.replace(' GMT', '');
35+
}
36+
37+
function taskPath(repoRoot: string, ref: string): string {
38+
if (!TASK_ID_RE.test(ref)) {
39+
const resolved = resolveTaskRef(ref);
40+
if (!resolved.ok) throw new Error(resolved.message);
41+
if (!resolved.taskDir.includes(`${path.join('.agents', 'workspace', 'active')}${path.sep}`)) {
42+
throw new Error(`task ${resolved.taskId} is not active`);
43+
}
44+
return resolved.taskMdPath;
45+
}
46+
const candidate = path.join(repoRoot, '.agents', 'workspace', 'active', ref, 'task.md');
47+
if (!fs.existsSync(candidate)) throw new Error(`active task not found: ${ref}`);
48+
return candidate;
49+
}
50+
51+
function replaceFrontmatterField(content: string, field: string, value: string): string {
52+
const re = new RegExp(`^${field}:.*$`, 'm');
53+
if (re.test(content)) return content.replace(re, `${field}: ${value}`);
54+
return content.replace(/^---\n/, `---\n${field}: ${value}\n`);
55+
}
56+
57+
function replaceLedgerRow(content: string, hdId: string): { content: string; found: boolean; pending: boolean } {
58+
const lines = content.split('\n');
59+
let found = false;
60+
let pending = false;
61+
for (let i = 0; i < lines.length; i += 1) {
62+
const line = lines[i] as string;
63+
if (!line.trim().startsWith(`| ${hdId} |`)) continue;
64+
found = true;
65+
const cells = line.split('|').slice(1, -1).map((cell) => cell.trim());
66+
if (cells[4] !== 'needs-human-decision') break;
67+
pending = true;
68+
cells[4] = 'human-decided';
69+
cells[5] = `task.md#${hdId}`;
70+
lines[i] = `| ${cells.join(' | ')} |`;
71+
break;
72+
}
73+
return { content: lines.join('\n'), found, pending };
74+
}
75+
76+
function appendUnderHeading(content: string, heading: string, block: string): string {
77+
if (!content.includes(`${heading}\n`)) {
78+
return `${content.trimEnd()}\n\n${heading}\n\n${block}\n`;
79+
}
80+
const idx = content.indexOf(`${heading}\n`) + heading.length + 1;
81+
const before = content.slice(0, idx);
82+
const after = content.slice(idx);
83+
return `${before}\n${block}\n${after.replace(/^\n/, '')}`;
84+
}
85+
86+
export async function decide(args: string[], options: DecideOptions = {}): Promise<number> {
87+
const [taskRef, hdId, ...decisionParts] = args;
88+
if (!taskRef || !hdId || decisionParts.length === 0) {
89+
process.stderr.write('Usage: ai decide <task-ref> <HD-id> <decision>\n');
90+
return 1;
91+
}
92+
try {
93+
const repoRoot = options.repoRoot ?? detectRepoRoot();
94+
const file = taskPath(repoRoot, taskRef);
95+
let content = fs.readFileSync(file, 'utf8');
96+
const replaced = replaceLedgerRow(content, hdId);
97+
if (!replaced.found) throw new Error(`${hdId} not found in review ledger`);
98+
if (!replaced.pending) throw new Error(`${hdId} is not needs-human-decision`);
99+
content = replaced.content;
100+
const now = (options.now ?? defaultNow)();
101+
content = replaceFrontmatterField(content, 'updated_at', now);
102+
content = replaceFrontmatterField(content, 'agent_infra_version', options.version ?? VERSION);
103+
const decision = decisionParts.join(' ');
104+
content = appendUnderHeading(
105+
content,
106+
'## 人工裁决',
107+
`### ${hdId}\n\n- **裁决时间**:${now}\n- **裁决结果**:${decision}`
108+
);
109+
content = appendUnderHeading(
110+
content,
111+
'## 活动日志',
112+
`- ${now} — **Human Decision** by human — ${hdId} decided`
113+
);
114+
fs.writeFileSync(file, content);
115+
return 0;
116+
} catch (error) {
117+
process.stderr.write(`Error: ${error instanceof Error ? error.message : String(error)}\n`);
118+
return 1;
119+
}
120+
}
121+
122+
export async function cmdDecide(args: string[]): Promise<void> {
123+
process.exitCode = await decide(args);
124+
}

lib/run/host.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { spawn } from 'node:child_process';
2+
3+
export type RunProcessResult = {
4+
exitCode: number | null;
5+
signal?: NodeJS.Signals | null;
6+
stdout?: string;
7+
stderr?: string;
8+
};
9+
10+
export async function runHostCommand(command: string[]): Promise<RunProcessResult> {
11+
const [file, ...args] = command;
12+
if (!file) throw new Error('run: missing command');
13+
return new Promise((resolve, reject) => {
14+
const child = spawn(file, args, { stdio: ['ignore', 'inherit', 'inherit'] });
15+
child.on('error', reject);
16+
child.on('close', (exitCode, signal) => resolve({ exitCode, signal }));
17+
});
18+
}

0 commit comments

Comments
 (0)