nexus-agentd 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +253 -0
- package/dist/acp/runtime.d.ts +26 -0
- package/dist/acp/runtime.js +420 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +24 -0
- package/dist/config.d.ts +3 -0
- package/dist/config.js +115 -0
- package/dist/drivers/claude.d.ts +2 -0
- package/dist/drivers/claude.js +9 -0
- package/dist/drivers/codex.d.ts +2 -0
- package/dist/drivers/codex.js +9 -0
- package/dist/drivers/index.d.ts +4 -0
- package/dist/drivers/index.js +30 -0
- package/dist/drivers/openclaw.d.ts +2 -0
- package/dist/drivers/openclaw.js +13 -0
- package/dist/drivers/opencode.d.ts +2 -0
- package/dist/drivers/opencode.js +9 -0
- package/dist/drivers/pi.d.ts +13 -0
- package/dist/drivers/pi.js +31 -0
- package/dist/drivers/stdio.d.ts +13 -0
- package/dist/drivers/stdio.js +114 -0
- package/dist/drivers/types.d.ts +14 -0
- package/dist/drivers/types.js +1 -0
- package/dist/events.d.ts +13 -0
- package/dist/events.js +40 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +48 -0
- package/dist/server.d.ts +4 -0
- package/dist/server.js +170 -0
- package/dist/session-contract.d.ts +12 -0
- package/dist/session-contract.js +1 -0
- package/dist/session.d.ts +26 -0
- package/dist/session.js +210 -0
- package/dist/types.d.ts +80 -0
- package/dist/types.js +7 -0
- package/dist/workspace.d.ts +7 -0
- package/dist/workspace.js +34 -0
- package/nexus-agentd.example.json +43 -0
- package/package.json +52 -0
package/README.md
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
# nexus-agentd
|
|
2
|
+
|
|
3
|
+
`nexus-agentd` 运行在 Coding Agent 所在机器,通过 Bearer Token 保护的 HTTP/SSE Gateway
|
|
4
|
+
向 AgentNexus 暴露本机白名单 Agent。当前支持 OpenCode、Claude Code、Codex、Pi 和
|
|
5
|
+
OpenClaw ACP Driver;Hermes 使用其原生 A2A Server,不经过 agentd。
|
|
6
|
+
|
|
7
|
+
当前部署目标是 Linux/Unix Agent 主机。
|
|
8
|
+
|
|
9
|
+
```text
|
|
10
|
+
AgentNexus
|
|
11
|
+
│ HTTP/SSE
|
|
12
|
+
▼
|
|
13
|
+
nexus-agentd
|
|
14
|
+
│ ACP stdio
|
|
15
|
+
▼
|
|
16
|
+
OpenCode / Claude Code / Codex / Pi / OpenClaw
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## 安装与启动
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm install -g nexus-agentd
|
|
23
|
+
curl -fsSL \
|
|
24
|
+
https://raw.githubusercontent.com/lumia1998/koishi-plugin-agent-nexus/main/packages/nexus-agentd/nexus-agentd.example.json \
|
|
25
|
+
-o nexus-agentd.json
|
|
26
|
+
export NEXUS_AGENTD_TOKEN='TOKEN'
|
|
27
|
+
nexus-agentd --config nexus-agentd.json
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
OpenCode 和 OpenClaw 自带 ACP 入口。Claude Code、Codex、Pi 需要先安装对应 adapter:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
npm install -g \
|
|
34
|
+
@agentclientprotocol/claude-agent-acp \
|
|
35
|
+
@agentclientprotocol/codex-acp \
|
|
36
|
+
pi-acp
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
还需要分别完成各 Agent 自身的登录或 API Key 配置。`claude-agent-acp` 当前要求 Node.js 22
|
|
40
|
+
或更高版本;其他 adapter 的版本要求以各自发布包为准。
|
|
41
|
+
|
|
42
|
+
也可以设置配置路径:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
export NEXUS_AGENTD_CONFIG=/etc/agent-nexus/nexus-agentd.json
|
|
46
|
+
nexus-agentd
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
从仓库开发:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
npm install
|
|
53
|
+
npm run build
|
|
54
|
+
node dist/cli.js --config nexus-agentd.json
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## 配置
|
|
58
|
+
|
|
59
|
+
```json
|
|
60
|
+
{
|
|
61
|
+
"listen": {
|
|
62
|
+
"host": "127.0.0.1",
|
|
63
|
+
"port": 8787
|
|
64
|
+
},
|
|
65
|
+
"authToken": "env:NEXUS_AGENTD_TOKEN",
|
|
66
|
+
"workspaceRoots": [
|
|
67
|
+
"/data/repos"
|
|
68
|
+
],
|
|
69
|
+
"agents": {
|
|
70
|
+
"opencode": {
|
|
71
|
+
"driver": "opencode",
|
|
72
|
+
"command": "opencode",
|
|
73
|
+
"args": ["acp"],
|
|
74
|
+
"permissionPolicy": "ask",
|
|
75
|
+
"inheritEnv": [
|
|
76
|
+
"OPENAI_API_KEY",
|
|
77
|
+
"ANTHROPIC_API_KEY"
|
|
78
|
+
]
|
|
79
|
+
},
|
|
80
|
+
"claude": {
|
|
81
|
+
"driver": "claude",
|
|
82
|
+
"command": "claude-agent-acp",
|
|
83
|
+
"permissionPolicy": "ask"
|
|
84
|
+
},
|
|
85
|
+
"codex": {
|
|
86
|
+
"driver": "codex",
|
|
87
|
+
"command": "codex-acp",
|
|
88
|
+
"permissionPolicy": "ask"
|
|
89
|
+
},
|
|
90
|
+
"pi": {
|
|
91
|
+
"driver": "pi",
|
|
92
|
+
"command": "pi-acp",
|
|
93
|
+
"permissionPolicy": "ask"
|
|
94
|
+
},
|
|
95
|
+
"openclaw": {
|
|
96
|
+
"driver": "openclaw",
|
|
97
|
+
"command": "openclaw",
|
|
98
|
+
"args": ["acp"],
|
|
99
|
+
"permissionPolicy": "ask"
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
主要字段:
|
|
106
|
+
|
|
107
|
+
| 字段 | 说明 |
|
|
108
|
+
|---|---|
|
|
109
|
+
| `listen.host` | 默认 `127.0.0.1`;LAN 访问需显式改为内网地址或 `0.0.0.0` |
|
|
110
|
+
| `listen.port` | HTTP/SSE 监听端口,默认 `8787` |
|
|
111
|
+
| `authToken` | 必填 Bearer Token,支持 `env:VAR` |
|
|
112
|
+
| `workspaceRoots` | 允许启动 ACP Session 的目录根列表 |
|
|
113
|
+
| `agents` | daemon 本地 Agent 白名单 |
|
|
114
|
+
| `driver` | `opencode`、`claude`、`codex`、`pi` 或 `openclaw` |
|
|
115
|
+
| `permissionPolicy` | `ask` 或 `deny`,默认 `ask` |
|
|
116
|
+
| `permissionTimeoutMs` | 权限或输入请求等待时间 |
|
|
117
|
+
| `inheritEnv` | 额外允许传给 Agent 子进程的本机环境变量名 |
|
|
118
|
+
| `env` | 显式环境变量,值支持 `env:VAR` |
|
|
119
|
+
|
|
120
|
+
默认入口:
|
|
121
|
+
|
|
122
|
+
| Driver | 默认命令 | 说明 |
|
|
123
|
+
|---|---|---|
|
|
124
|
+
| `opencode` | `opencode acp` | OpenCode 原生 ACP Server |
|
|
125
|
+
| `claude` | `claude-agent-acp` | `@agentclientprotocol/claude-agent-acp` |
|
|
126
|
+
| `codex` | `codex-acp` | `@agentclientprotocol/codex-acp`,自带兼容 Codex 依赖 |
|
|
127
|
+
| `pi` | `pi-acp` | adapter 再启动本机 `pi --mode rpc` |
|
|
128
|
+
| `openclaw` | `openclaw acp` | OpenClaw 原生 Gateway ACP Bridge |
|
|
129
|
+
|
|
130
|
+
OpenClaw Gateway 必须已运行并可被 `openclaw acp` 访问。可通过 OpenClaw 本机配置保存 Gateway
|
|
131
|
+
地址和凭据,也可在 `args` 中使用 `--url` 与 `--token-file`。不要把明文 Token 提交到仓库。
|
|
132
|
+
|
|
133
|
+
Pi Driver 会同时探测 `pi-acp` 和底层 `pi`。如果 Pi 可执行文件不是默认名称,通过
|
|
134
|
+
`env.PI_ACP_PI_COMMAND` 指定。
|
|
135
|
+
|
|
136
|
+
`command` 和 `args` 只从 agentd 本机配置读取。HTTP 客户端不能覆盖它们。
|
|
137
|
+
|
|
138
|
+
## API
|
|
139
|
+
|
|
140
|
+
```text
|
|
141
|
+
GET /health
|
|
142
|
+
GET /v1/agents
|
|
143
|
+
POST /v1/sessions
|
|
144
|
+
GET /v1/sessions/:id
|
|
145
|
+
POST /v1/sessions/:id/message
|
|
146
|
+
POST /v1/sessions/:id/cancel
|
|
147
|
+
GET /v1/sessions/:id/events
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
除 `/health` 外都要求:
|
|
151
|
+
|
|
152
|
+
```http
|
|
153
|
+
Authorization: Bearer TOKEN
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
创建 Session 的请求只接受:
|
|
157
|
+
|
|
158
|
+
```json
|
|
159
|
+
{
|
|
160
|
+
"agentId": "claude",
|
|
161
|
+
"workspace": "/data/repos/project"
|
|
162
|
+
}
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
发送消息只接受:
|
|
166
|
+
|
|
167
|
+
```json
|
|
168
|
+
{
|
|
169
|
+
"message": "检查并修复测试"
|
|
170
|
+
}
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
额外字段会返回 `400`,因此客户端不能传入 command、argv 或 shell payload。
|
|
174
|
+
|
|
175
|
+
## Session 与事件
|
|
176
|
+
|
|
177
|
+
一个 Gateway Session 对应一个 ACP 子进程和一个 ACP Session。Session 完成后再次发送消息,
|
|
178
|
+
会续接同一 ACP Session;每轮输出单独保存,不与上一轮文本拼接。
|
|
179
|
+
|
|
180
|
+
状态:
|
|
181
|
+
|
|
182
|
+
```text
|
|
183
|
+
created
|
|
184
|
+
running
|
|
185
|
+
input_required
|
|
186
|
+
permission_required
|
|
187
|
+
completed
|
|
188
|
+
failed
|
|
189
|
+
canceled
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
SSE 事件包括:
|
|
193
|
+
|
|
194
|
+
```text
|
|
195
|
+
session_state
|
|
196
|
+
assistant_chunk
|
|
197
|
+
thought_chunk
|
|
198
|
+
plan
|
|
199
|
+
tool_call
|
|
200
|
+
tool_update
|
|
201
|
+
terminal_output
|
|
202
|
+
file_activity
|
|
203
|
+
permission_required
|
|
204
|
+
input_required
|
|
205
|
+
completed
|
|
206
|
+
failed
|
|
207
|
+
canceled
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
`/events` 支持 `after` 查询参数和 `Last-Event-ID`,用于重放内存事件日志中的后续事件。
|
|
211
|
+
|
|
212
|
+
Session 当前保存在内存中。agentd 重启会终止 ACP 子进程,旧 Gateway Session 不可恢复。
|
|
213
|
+
|
|
214
|
+
## 权限与输入
|
|
215
|
+
|
|
216
|
+
`permissionPolicy=ask` 时,ACP permission request 会进入 `permission_required`,Session 响应
|
|
217
|
+
包含问题和选项。AgentNexus 通过 `message` 返回:
|
|
218
|
+
|
|
219
|
+
- 选项序号,例如 `1`
|
|
220
|
+
- option ID
|
|
221
|
+
- option name
|
|
222
|
+
- `deny`、`cancel`、`拒绝` 或 `取消`
|
|
223
|
+
|
|
224
|
+
ACP elicitation 会进入 `input_required`。简单单字段表单可直接回复文本、数字、布尔值或列表;
|
|
225
|
+
多字段结构化输入使用 JSON object。
|
|
226
|
+
|
|
227
|
+
`permissionPolicy=deny` 会选择协议提供的拒绝选项,或在没有拒绝选项时取消请求。
|
|
228
|
+
|
|
229
|
+
## Workspace 安全
|
|
230
|
+
|
|
231
|
+
workspace 在启动 ACP 子进程前执行:
|
|
232
|
+
|
|
233
|
+
1. `realpath(workspace)`。
|
|
234
|
+
2. `realpath` 每个 `workspaceRoots`。
|
|
235
|
+
3. 使用路径边界检查确认 workspace 位于允许根内。
|
|
236
|
+
|
|
237
|
+
这会拒绝 `../` 逃逸、允许根之外的绝对路径和 symlink/junction 逃逸。workspace 必须已经存在。
|
|
238
|
+
|
|
239
|
+
## 网络建议
|
|
240
|
+
|
|
241
|
+
- 默认保持 `127.0.0.1`。
|
|
242
|
+
- LAN 监听时使用强随机 Token 和主机防火墙限制 Koishi 来源 IP。
|
|
243
|
+
- 不要直接暴露到公网;需要跨网络时在前面部署 HTTPS/mTLS 反向代理。
|
|
244
|
+
- 使用权限受限的专用系统账号运行 agentd。
|
|
245
|
+
|
|
246
|
+
## 验证
|
|
247
|
+
|
|
248
|
+
```bash
|
|
249
|
+
npm test
|
|
250
|
+
npm run typecheck
|
|
251
|
+
npm run build
|
|
252
|
+
npm pack --dry-run --json
|
|
253
|
+
```
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { AgentDriver } from '../drivers/index.js';
|
|
2
|
+
import type { AcpSessionSink } from '../session-contract.js';
|
|
3
|
+
export declare class AcpProcessRuntime {
|
|
4
|
+
private readonly driver;
|
|
5
|
+
private readonly sink;
|
|
6
|
+
private process?;
|
|
7
|
+
private connection?;
|
|
8
|
+
private pending?;
|
|
9
|
+
private pendingInput?;
|
|
10
|
+
private disposed;
|
|
11
|
+
private prompting;
|
|
12
|
+
constructor(driver: AgentDriver, sink: AcpSessionSink);
|
|
13
|
+
start(workspace: string): Promise<void>;
|
|
14
|
+
prompt(message: string): Promise<void>;
|
|
15
|
+
respondPending(message: string): Promise<void>;
|
|
16
|
+
cancel(): Promise<void>;
|
|
17
|
+
dispose(): Promise<void>;
|
|
18
|
+
private requestPermission;
|
|
19
|
+
private finishPermission;
|
|
20
|
+
private requestInput;
|
|
21
|
+
private finishInput;
|
|
22
|
+
private sessionUpdate;
|
|
23
|
+
private captureStderr;
|
|
24
|
+
private onProcessFailure;
|
|
25
|
+
private requireSessionId;
|
|
26
|
+
}
|
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { Readable, Writable } from 'node:stream';
|
|
3
|
+
import * as acp from '@agentclientprotocol/sdk';
|
|
4
|
+
export class AcpProcessRuntime {
|
|
5
|
+
driver;
|
|
6
|
+
sink;
|
|
7
|
+
process;
|
|
8
|
+
connection;
|
|
9
|
+
pending;
|
|
10
|
+
pendingInput;
|
|
11
|
+
disposed = false;
|
|
12
|
+
prompting = false;
|
|
13
|
+
constructor(driver, sink) {
|
|
14
|
+
this.driver = driver;
|
|
15
|
+
this.sink = sink;
|
|
16
|
+
}
|
|
17
|
+
async start(workspace) {
|
|
18
|
+
if (this.process)
|
|
19
|
+
throw new Error('ACP runtime is already started');
|
|
20
|
+
const child = this.driver.spawn(workspace);
|
|
21
|
+
this.process = child;
|
|
22
|
+
this.captureStderr(child);
|
|
23
|
+
child.once('error', (error) => this.onProcessFailure(error));
|
|
24
|
+
child.once('exit', (code, signal) => {
|
|
25
|
+
if (this.disposed)
|
|
26
|
+
return;
|
|
27
|
+
if (this.sink.state === 'canceled' || this.sink.state === 'completed')
|
|
28
|
+
return;
|
|
29
|
+
this.onProcessFailure(new Error(`ACP agent exited unexpectedly (${signal || code || 'unknown'})`));
|
|
30
|
+
});
|
|
31
|
+
const app = acp
|
|
32
|
+
.client({ name: 'nexus-agentd' })
|
|
33
|
+
.onRequest(acp.methods.client.session.requestPermission, ({ params }) => this.requestPermission(params))
|
|
34
|
+
.onRequest(acp.methods.client.elicitation.create, ({ params }) => this.requestInput(params))
|
|
35
|
+
.onNotification(acp.methods.client.session.update, ({ params }) => {
|
|
36
|
+
this.sessionUpdate(params);
|
|
37
|
+
});
|
|
38
|
+
const stream = acp.ndJsonStream(Writable.toWeb(child.stdin), Readable.toWeb(child.stdout));
|
|
39
|
+
this.connection = app.connect(stream);
|
|
40
|
+
const initialize = await this.connection.agent.request(acp.methods.agent.initialize, {
|
|
41
|
+
protocolVersion: acp.PROTOCOL_VERSION,
|
|
42
|
+
clientCapabilities: {
|
|
43
|
+
elicitation: {
|
|
44
|
+
form: {},
|
|
45
|
+
url: {}
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
clientInfo: {
|
|
49
|
+
name: 'nexus-agentd',
|
|
50
|
+
version: '0.1.0'
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
if (initialize.protocolVersion !== acp.PROTOCOL_VERSION) {
|
|
54
|
+
this.sink.emit('terminal_output', {
|
|
55
|
+
stream: 'system',
|
|
56
|
+
text: `ACP negotiated protocol ${initialize.protocolVersion}`
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
const session = await this.connection.agent.request(acp.methods.agent.session.new, {
|
|
60
|
+
cwd: workspace,
|
|
61
|
+
mcpServers: []
|
|
62
|
+
});
|
|
63
|
+
this.sink.setAcpSessionId(String(session.sessionId));
|
|
64
|
+
this.sink.setState('created');
|
|
65
|
+
}
|
|
66
|
+
async prompt(message) {
|
|
67
|
+
if (!this.connection)
|
|
68
|
+
throw new Error('ACP runtime is not connected');
|
|
69
|
+
if (this.prompting)
|
|
70
|
+
throw new Error('ACP session is already processing a prompt');
|
|
71
|
+
const sessionId = this.requireSessionId();
|
|
72
|
+
this.prompting = true;
|
|
73
|
+
this.sink.clearPending();
|
|
74
|
+
this.sink.setState('running');
|
|
75
|
+
try {
|
|
76
|
+
const response = await this.connection.agent.request(acp.methods.agent.session.prompt, {
|
|
77
|
+
sessionId,
|
|
78
|
+
prompt: [{ type: 'text', text: message }]
|
|
79
|
+
});
|
|
80
|
+
if (this.sink.state === 'canceled')
|
|
81
|
+
return;
|
|
82
|
+
if (response.stopReason === 'cancelled') {
|
|
83
|
+
this.sink.setState('canceled');
|
|
84
|
+
}
|
|
85
|
+
else if (response.stopReason === 'refusal') {
|
|
86
|
+
this.sink.setState('failed', 'ACP agent refused the prompt');
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
this.sink.setState('completed');
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
if (this.sink.state !== 'canceled') {
|
|
94
|
+
this.sink.setState('failed', error instanceof Error ? error.message : String(error));
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
finally {
|
|
98
|
+
this.prompting = false;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
async respondPending(message) {
|
|
102
|
+
if (this.pendingInput) {
|
|
103
|
+
this.finishInput(message);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const pending = this.pending;
|
|
107
|
+
if (!pending)
|
|
108
|
+
throw new Error('ACP session is not waiting for input');
|
|
109
|
+
const normalized = message.trim().toLowerCase();
|
|
110
|
+
const options = pending.request.options || [];
|
|
111
|
+
if (['cancel', 'deny', 'reject', '拒绝', '取消', '不同意'].includes(normalized)) {
|
|
112
|
+
this.finishPermission({ outcome: { outcome: 'cancelled' } });
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const numeric = Number(normalized);
|
|
116
|
+
const option = Number.isInteger(numeric) && numeric >= 1
|
|
117
|
+
? options[numeric - 1]
|
|
118
|
+
: options.find((item) => item.id.toLowerCase() === normalized ||
|
|
119
|
+
item.name.toLowerCase() === normalized);
|
|
120
|
+
if (!option) {
|
|
121
|
+
throw new Error(`Permission answer must be an option id/name or index: ${options
|
|
122
|
+
.map((item, index) => `${index + 1}. ${item.name} (${item.id})`)
|
|
123
|
+
.join('; ')}`);
|
|
124
|
+
}
|
|
125
|
+
this.finishPermission({
|
|
126
|
+
outcome: {
|
|
127
|
+
outcome: 'selected',
|
|
128
|
+
optionId: option.id
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
async cancel() {
|
|
133
|
+
if (this.pending) {
|
|
134
|
+
this.finishPermission({ outcome: { outcome: 'cancelled' } });
|
|
135
|
+
}
|
|
136
|
+
if (this.pendingInput) {
|
|
137
|
+
this.finishInput('cancel');
|
|
138
|
+
}
|
|
139
|
+
if (this.connection && this.sink.state !== 'canceled') {
|
|
140
|
+
try {
|
|
141
|
+
await this.connection.agent.notify(acp.methods.agent.session.cancel, {
|
|
142
|
+
sessionId: this.requireSessionId()
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
this.sink.emit('terminal_output', {
|
|
147
|
+
stream: 'system',
|
|
148
|
+
text: `ACP cancellation notification failed: ${error instanceof Error ? error.message : String(error)}`
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
this.sink.setState('canceled');
|
|
153
|
+
await this.dispose();
|
|
154
|
+
}
|
|
155
|
+
async dispose() {
|
|
156
|
+
if (this.disposed)
|
|
157
|
+
return;
|
|
158
|
+
this.disposed = true;
|
|
159
|
+
if (this.pending) {
|
|
160
|
+
clearTimeout(this.pending.timer);
|
|
161
|
+
this.pending.reject(new Error('ACP runtime disposed'));
|
|
162
|
+
this.pending = undefined;
|
|
163
|
+
}
|
|
164
|
+
if (this.pendingInput) {
|
|
165
|
+
clearTimeout(this.pendingInput.timer);
|
|
166
|
+
this.pendingInput.resolve({ action: 'cancel' });
|
|
167
|
+
this.pendingInput = undefined;
|
|
168
|
+
}
|
|
169
|
+
this.connection?.close();
|
|
170
|
+
this.connection = undefined;
|
|
171
|
+
const child = this.process;
|
|
172
|
+
this.process = undefined;
|
|
173
|
+
if (child && child.exitCode === null && child.signalCode === null) {
|
|
174
|
+
child.kill();
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
requestPermission(params) {
|
|
178
|
+
if (this.driver.permissionPolicy === 'deny') {
|
|
179
|
+
const reject = params.options.find((option) => option.kind.startsWith('reject'));
|
|
180
|
+
return reject
|
|
181
|
+
? {
|
|
182
|
+
outcome: {
|
|
183
|
+
outcome: 'selected',
|
|
184
|
+
optionId: reject.optionId
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
: { outcome: { outcome: 'cancelled' } };
|
|
188
|
+
}
|
|
189
|
+
if (this.pending) {
|
|
190
|
+
return { outcome: { outcome: 'cancelled' } };
|
|
191
|
+
}
|
|
192
|
+
const request = {
|
|
193
|
+
id: randomUUID(),
|
|
194
|
+
kind: 'permission',
|
|
195
|
+
prompt: params.toolCall.title ||
|
|
196
|
+
`Permission requested for tool ${params.toolCall.toolCallId}`,
|
|
197
|
+
options: params.options.map((option) => ({
|
|
198
|
+
id: option.optionId,
|
|
199
|
+
name: option.name,
|
|
200
|
+
kind: option.kind
|
|
201
|
+
}))
|
|
202
|
+
};
|
|
203
|
+
this.sink.setPending(request);
|
|
204
|
+
return new Promise((resolve, reject) => {
|
|
205
|
+
const timer = setTimeout(() => {
|
|
206
|
+
if (this.pending?.request.id !== request.id)
|
|
207
|
+
return;
|
|
208
|
+
this.pending = undefined;
|
|
209
|
+
this.sink.clearPending();
|
|
210
|
+
this.sink.setState('failed', 'ACP permission request timed out');
|
|
211
|
+
resolve({ outcome: { outcome: 'cancelled' } });
|
|
212
|
+
}, this.driver.permissionTimeoutMs);
|
|
213
|
+
this.pending = { request, resolve, reject, timer };
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
finishPermission(response) {
|
|
217
|
+
const pending = this.pending;
|
|
218
|
+
if (!pending)
|
|
219
|
+
return;
|
|
220
|
+
this.pending = undefined;
|
|
221
|
+
clearTimeout(pending.timer);
|
|
222
|
+
this.sink.clearPending();
|
|
223
|
+
this.sink.setState('running');
|
|
224
|
+
pending.resolve(response);
|
|
225
|
+
}
|
|
226
|
+
requestInput(params) {
|
|
227
|
+
if (this.pending || this.pendingInput) {
|
|
228
|
+
return Promise.resolve({ action: 'cancel' });
|
|
229
|
+
}
|
|
230
|
+
const request = {
|
|
231
|
+
id: randomUUID(),
|
|
232
|
+
kind: 'input',
|
|
233
|
+
prompt: params.mode === 'url'
|
|
234
|
+
? `${params.message}\n${params.url}`
|
|
235
|
+
: params.message,
|
|
236
|
+
options: elicitationOptions(params)
|
|
237
|
+
};
|
|
238
|
+
this.sink.setPending(request);
|
|
239
|
+
return new Promise((resolve) => {
|
|
240
|
+
const timer = setTimeout(() => {
|
|
241
|
+
if (this.pendingInput?.request.id !== request.id)
|
|
242
|
+
return;
|
|
243
|
+
this.pendingInput = undefined;
|
|
244
|
+
this.sink.clearPending();
|
|
245
|
+
this.sink.setState('failed', 'ACP input request timed out');
|
|
246
|
+
resolve({ action: 'cancel' });
|
|
247
|
+
}, this.driver.permissionTimeoutMs);
|
|
248
|
+
this.pendingInput = { request, params, resolve, timer };
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
finishInput(message) {
|
|
252
|
+
const pending = this.pendingInput;
|
|
253
|
+
if (!pending)
|
|
254
|
+
return;
|
|
255
|
+
const normalized = message.trim().toLowerCase();
|
|
256
|
+
let response;
|
|
257
|
+
if (['cancel', '取消'].includes(normalized)) {
|
|
258
|
+
response = { action: 'cancel' };
|
|
259
|
+
}
|
|
260
|
+
else if (['decline', '拒绝', '不同意'].includes(normalized)) {
|
|
261
|
+
response = { action: 'decline' };
|
|
262
|
+
}
|
|
263
|
+
else if (!isFormElicitation(pending.params)) {
|
|
264
|
+
response = { action: 'accept' };
|
|
265
|
+
}
|
|
266
|
+
else {
|
|
267
|
+
response = {
|
|
268
|
+
action: 'accept',
|
|
269
|
+
content: elicitationContent(pending.params, message)
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
this.pendingInput = undefined;
|
|
273
|
+
clearTimeout(pending.timer);
|
|
274
|
+
this.sink.clearPending();
|
|
275
|
+
this.sink.setState('running');
|
|
276
|
+
pending.resolve(response);
|
|
277
|
+
}
|
|
278
|
+
sessionUpdate(params) {
|
|
279
|
+
const update = params.update;
|
|
280
|
+
switch (update.sessionUpdate) {
|
|
281
|
+
case 'agent_message_chunk':
|
|
282
|
+
if (update.content.type === 'text') {
|
|
283
|
+
this.sink.appendOutput(update.content.text);
|
|
284
|
+
this.sink.emit('assistant_chunk', {
|
|
285
|
+
messageId: update.messageId,
|
|
286
|
+
content: update.content
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
break;
|
|
290
|
+
case 'agent_thought_chunk':
|
|
291
|
+
this.sink.emit('thought_chunk', update);
|
|
292
|
+
break;
|
|
293
|
+
case 'tool_call':
|
|
294
|
+
this.sink.emit('tool_call', update);
|
|
295
|
+
if (update.locations?.length) {
|
|
296
|
+
this.sink.emit('file_activity', {
|
|
297
|
+
toolCallId: update.toolCallId,
|
|
298
|
+
locations: update.locations
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
break;
|
|
302
|
+
case 'tool_call_update':
|
|
303
|
+
this.sink.emit('tool_update', update);
|
|
304
|
+
if (update.locations?.length) {
|
|
305
|
+
this.sink.emit('file_activity', {
|
|
306
|
+
toolCallId: update.toolCallId,
|
|
307
|
+
locations: update.locations
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
break;
|
|
311
|
+
case 'plan':
|
|
312
|
+
case 'plan_update':
|
|
313
|
+
case 'plan_removed':
|
|
314
|
+
this.sink.emit('plan', update);
|
|
315
|
+
break;
|
|
316
|
+
default:
|
|
317
|
+
break;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
captureStderr(child) {
|
|
321
|
+
let buffer = '';
|
|
322
|
+
child.stderr.on('data', (chunk) => {
|
|
323
|
+
buffer += String(chunk);
|
|
324
|
+
const lines = buffer.split(/\r?\n/);
|
|
325
|
+
buffer = lines.pop() || '';
|
|
326
|
+
for (const line of lines) {
|
|
327
|
+
if (line) {
|
|
328
|
+
this.sink.emit('terminal_output', {
|
|
329
|
+
stream: 'stderr',
|
|
330
|
+
text: line
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
onProcessFailure(error) {
|
|
337
|
+
if (this.disposed)
|
|
338
|
+
return;
|
|
339
|
+
this.sink.setState('failed', error instanceof Error ? error.message : String(error));
|
|
340
|
+
}
|
|
341
|
+
requireSessionId() {
|
|
342
|
+
const value = this.sink.acpSessionId;
|
|
343
|
+
if (typeof value !== 'string' || !value) {
|
|
344
|
+
throw new Error('ACP session has not been initialized');
|
|
345
|
+
}
|
|
346
|
+
return value;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
function elicitationOptions(params) {
|
|
350
|
+
if (!isFormElicitation(params))
|
|
351
|
+
return undefined;
|
|
352
|
+
const properties = params.requestedSchema.properties || {};
|
|
353
|
+
if (Object.keys(properties).length !== 1)
|
|
354
|
+
return undefined;
|
|
355
|
+
const schema = Object.values(properties)[0];
|
|
356
|
+
if (schema.type !== 'string')
|
|
357
|
+
return undefined;
|
|
358
|
+
const oneOf = Array.isArray(schema.oneOf)
|
|
359
|
+
? schema.oneOf
|
|
360
|
+
: [];
|
|
361
|
+
if (oneOf.length) {
|
|
362
|
+
return oneOf.map((option) => ({
|
|
363
|
+
id: String(option.const),
|
|
364
|
+
name: String(option.title),
|
|
365
|
+
kind: 'input'
|
|
366
|
+
}));
|
|
367
|
+
}
|
|
368
|
+
const values = Array.isArray(schema.enum)
|
|
369
|
+
? schema.enum
|
|
370
|
+
: [];
|
|
371
|
+
return values.map((value) => ({
|
|
372
|
+
id: String(value),
|
|
373
|
+
name: String(value),
|
|
374
|
+
kind: 'input'
|
|
375
|
+
}));
|
|
376
|
+
}
|
|
377
|
+
function elicitationContent(params, message) {
|
|
378
|
+
try {
|
|
379
|
+
const parsed = JSON.parse(message);
|
|
380
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
381
|
+
return parsed;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
catch { }
|
|
385
|
+
const properties = params.requestedSchema.properties || {};
|
|
386
|
+
const entries = Object.entries(properties);
|
|
387
|
+
if (entries.length !== 1) {
|
|
388
|
+
throw new Error('Structured ACP input requires a JSON object');
|
|
389
|
+
}
|
|
390
|
+
const [key, schema] = entries[0];
|
|
391
|
+
if (schema.type === 'boolean') {
|
|
392
|
+
const value = message.trim().toLowerCase();
|
|
393
|
+
if (['true', 'yes', '1', '是'].includes(value))
|
|
394
|
+
return { [key]: true };
|
|
395
|
+
if (['false', 'no', '0', '否'].includes(value))
|
|
396
|
+
return { [key]: false };
|
|
397
|
+
throw new Error('Boolean ACP input must be true/false or yes/no');
|
|
398
|
+
}
|
|
399
|
+
if (schema.type === 'number' || schema.type === 'integer') {
|
|
400
|
+
const value = Number(message);
|
|
401
|
+
if (!Number.isFinite(value))
|
|
402
|
+
throw new Error('ACP input must be a number');
|
|
403
|
+
return { [key]: schema.type === 'integer' ? Math.trunc(value) : value };
|
|
404
|
+
}
|
|
405
|
+
if (schema.type === 'array') {
|
|
406
|
+
return {
|
|
407
|
+
[key]: message
|
|
408
|
+
.split(/[,,\n]/)
|
|
409
|
+
.map((item) => item.trim())
|
|
410
|
+
.filter(Boolean)
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
return { [key]: message };
|
|
414
|
+
}
|
|
415
|
+
function isFormElicitation(params) {
|
|
416
|
+
return (params.mode === 'form' &&
|
|
417
|
+
Boolean(params.requestedSchema &&
|
|
418
|
+
typeof params.requestedSchema ===
|
|
419
|
+
'object'));
|
|
420
|
+
}
|
package/dist/cli.d.ts
ADDED