@botcord/daemon 0.2.5 → 0.2.8
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/dist/agent-discovery.d.ts +4 -0
- package/dist/agent-discovery.js +8 -0
- package/dist/agent-workspace.d.ts +62 -0
- package/dist/agent-workspace.js +140 -8
- package/dist/config.d.ts +64 -1
- package/dist/config.js +73 -1
- package/dist/daemon-config-map.d.ts +27 -9
- package/dist/daemon-config-map.js +105 -8
- package/dist/daemon.d.ts +2 -0
- package/dist/daemon.js +76 -6
- package/dist/doctor.d.ts +27 -1
- package/dist/doctor.js +22 -1
- package/dist/gateway/cli-resolver.d.ts +34 -0
- package/dist/gateway/cli-resolver.js +74 -0
- package/dist/gateway/dispatcher.d.ts +31 -1
- package/dist/gateway/dispatcher.js +337 -29
- package/dist/gateway/gateway.d.ts +29 -1
- package/dist/gateway/gateway.js +10 -0
- package/dist/gateway/index.d.ts +2 -0
- package/dist/gateway/index.js +2 -0
- package/dist/gateway/policy-resolver.d.ts +57 -0
- package/dist/gateway/policy-resolver.js +123 -0
- package/dist/gateway/runtimes/acp-stream.d.ts +99 -0
- package/dist/gateway/runtimes/acp-stream.js +394 -0
- package/dist/gateway/runtimes/codex.js +7 -0
- package/dist/gateway/runtimes/hermes-agent.d.ts +83 -0
- package/dist/gateway/runtimes/hermes-agent.js +180 -0
- package/dist/gateway/runtimes/ndjson-stream.d.ts +7 -2
- package/dist/gateway/runtimes/ndjson-stream.js +16 -3
- package/dist/gateway/runtimes/openclaw-acp.d.ts +44 -0
- package/dist/gateway/runtimes/openclaw-acp.js +500 -0
- package/dist/gateway/runtimes/registry.d.ts +4 -0
- package/dist/gateway/runtimes/registry.js +22 -0
- package/dist/gateway/transcript-paths.d.ts +30 -0
- package/dist/gateway/transcript-paths.js +114 -0
- package/dist/gateway/transcript.d.ts +123 -0
- package/dist/gateway/transcript.js +147 -0
- package/dist/gateway/types.d.ts +31 -0
- package/dist/index.js +309 -27
- package/dist/mention-scan.d.ts +22 -0
- package/dist/mention-scan.js +35 -0
- package/dist/openclaw-discovery.d.ts +28 -0
- package/dist/openclaw-discovery.js +228 -0
- package/dist/provision.d.ts +113 -1
- package/dist/provision.js +564 -12
- package/dist/system-context.d.ts +5 -4
- package/dist/system-context.js +35 -5
- package/dist/url-utils.d.ts +9 -0
- package/dist/url-utils.js +18 -0
- package/package.json +3 -2
- package/src/__tests__/agent-workspace.test.ts +93 -0
- package/src/__tests__/daemon-config-map.test.ts +79 -0
- package/src/__tests__/openclaw-acp.test.ts +234 -0
- package/src/__tests__/openclaw-discovery.test.ts +150 -0
- package/src/__tests__/policy-resolver.test.ts +124 -0
- package/src/__tests__/policy-updated-handler.test.ts +144 -0
- package/src/__tests__/provision.test.ts +265 -0
- package/src/__tests__/system-context.test.ts +52 -0
- package/src/__tests__/url-utils.test.ts +37 -0
- package/src/agent-discovery.ts +8 -0
- package/src/agent-workspace.ts +173 -7
- package/src/config.ts +168 -4
- package/src/daemon-config-map.ts +154 -9
- package/src/daemon.ts +96 -6
- package/src/doctor.ts +49 -2
- package/src/gateway/__tests__/dispatcher.test.ts +65 -0
- package/src/gateway/__tests__/hermes-agent-adapter.test.ts +302 -0
- package/src/gateway/__tests__/transcript.test.ts +496 -0
- package/src/gateway/cli-resolver.ts +92 -0
- package/src/gateway/dispatcher.ts +394 -26
- package/src/gateway/gateway.ts +46 -0
- package/src/gateway/index.ts +25 -0
- package/src/gateway/policy-resolver.ts +171 -0
- package/src/gateway/runtimes/acp-stream.ts +535 -0
- package/src/gateway/runtimes/codex.ts +7 -0
- package/src/gateway/runtimes/hermes-agent.ts +206 -0
- package/src/gateway/runtimes/ndjson-stream.ts +16 -3
- package/src/gateway/runtimes/openclaw-acp.ts +606 -0
- package/src/gateway/runtimes/registry.ts +24 -0
- package/src/gateway/transcript-paths.ts +145 -0
- package/src/gateway/transcript.ts +300 -0
- package/src/gateway/types.ts +32 -0
- package/src/index.ts +321 -30
- package/src/mention-scan.ts +38 -0
- package/src/openclaw-discovery.ts +262 -0
- package/src/provision.ts +682 -14
- package/src/system-context.ts +41 -9
- package/src/url-utils.ts +17 -0
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { readCommandVersion, resolveCommandOnPath, } from "./probe.js";
|
|
3
|
+
import { consoleLogger } from "../log.js";
|
|
4
|
+
const log = consoleLogger;
|
|
5
|
+
const ACP_PROTOCOL_VERSION = 1;
|
|
6
|
+
/** How long an idle (no in-flight prompt) ACP child process is kept alive. */
|
|
7
|
+
const ACP_IDLE_TIMEOUT_MS = 5 * 60 * 1000;
|
|
8
|
+
/** Cap for streamed assistant text per turn. */
|
|
9
|
+
const ASSISTANT_TEXT_CAP = 1 * 1024 * 1024;
|
|
10
|
+
const ACP_POOL = new Map();
|
|
11
|
+
function poolKey(accountId, gatewayName) {
|
|
12
|
+
return `${accountId}::${gatewayName}`;
|
|
13
|
+
}
|
|
14
|
+
function resetIdle(h, key) {
|
|
15
|
+
if (h.idleTimer)
|
|
16
|
+
clearTimeout(h.idleTimer);
|
|
17
|
+
if (h.inFlight > 0)
|
|
18
|
+
return;
|
|
19
|
+
h.idleTimer = setTimeout(() => {
|
|
20
|
+
if (h.inFlight === 0 && !h.closed) {
|
|
21
|
+
log.info("openclaw-acp.idle-timeout", { key });
|
|
22
|
+
shutdownHandle(h, "idle-timeout");
|
|
23
|
+
ACP_POOL.delete(key);
|
|
24
|
+
}
|
|
25
|
+
}, ACP_IDLE_TIMEOUT_MS);
|
|
26
|
+
h.idleTimer.unref?.();
|
|
27
|
+
}
|
|
28
|
+
function shutdownHandle(h, reason) {
|
|
29
|
+
if (h.closed)
|
|
30
|
+
return;
|
|
31
|
+
h.closed = true;
|
|
32
|
+
h.exitReason = reason;
|
|
33
|
+
if (h.idleTimer)
|
|
34
|
+
clearTimeout(h.idleTimer);
|
|
35
|
+
for (const p of h.pending.values()) {
|
|
36
|
+
p.reject(new Error(`openclaw acp child closed: ${reason}`));
|
|
37
|
+
}
|
|
38
|
+
h.pending.clear();
|
|
39
|
+
h.subscribers.clear();
|
|
40
|
+
try {
|
|
41
|
+
h.child.kill("SIGTERM");
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
// already dead
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/** Test-only: drop all cached child processes. */
|
|
48
|
+
export function __resetOpenclawAcpPoolForTests() {
|
|
49
|
+
for (const [key, h] of ACP_POOL.entries()) {
|
|
50
|
+
shutdownHandle(h, "test-reset");
|
|
51
|
+
ACP_POOL.delete(key);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// Probe
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
function resolveOpenclawCommand(deps = {}) {
|
|
58
|
+
const explicit = (deps.env ?? process.env).BOTCORD_OPENCLAW_BIN;
|
|
59
|
+
if (explicit && explicit.length > 0)
|
|
60
|
+
return explicit;
|
|
61
|
+
return resolveCommandOnPath("openclaw", deps);
|
|
62
|
+
}
|
|
63
|
+
export function probeOpenclaw(deps = {}) {
|
|
64
|
+
const command = resolveOpenclawCommand(deps);
|
|
65
|
+
if (!command)
|
|
66
|
+
return { available: false };
|
|
67
|
+
return {
|
|
68
|
+
available: true,
|
|
69
|
+
path: command,
|
|
70
|
+
version: readCommandVersion(command, [], deps) ?? undefined,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* OpenClaw ACP runtime adapter.
|
|
75
|
+
*
|
|
76
|
+
* Spawns `openclaw acp --url <gateway> [--token <token>]` per
|
|
77
|
+
* `(accountId, gatewayName)` pair and reuses the process across turns. The
|
|
78
|
+
* child speaks JSON-RPC over stdio; we send `initialize` once, then
|
|
79
|
+
* `newSession` (with `_meta.sessionKey`) when the daemon has no persisted
|
|
80
|
+
* runtime session id, and `prompt` for each turn. Streaming `session/update`
|
|
81
|
+
* notifications are relayed to `onBlock`.
|
|
82
|
+
*
|
|
83
|
+
* Process-pool lifetime + abort/cancel semantics live at module scope; see
|
|
84
|
+
* `ACP_POOL` and `shutdownHandle` above.
|
|
85
|
+
*/
|
|
86
|
+
export class OpenclawAcpAdapter {
|
|
87
|
+
id = "openclaw-acp";
|
|
88
|
+
spawnFn;
|
|
89
|
+
constructor(deps = {}) {
|
|
90
|
+
this.spawnFn = deps.spawnFn ?? spawn;
|
|
91
|
+
}
|
|
92
|
+
probe() {
|
|
93
|
+
return probeOpenclaw();
|
|
94
|
+
}
|
|
95
|
+
async run(opts) {
|
|
96
|
+
const gateway = opts.gateway;
|
|
97
|
+
if (!gateway) {
|
|
98
|
+
return failResult(opts.sessionId ?? "", "openclaw-acp: missing gateway endpoint (route.gateway not resolved)");
|
|
99
|
+
}
|
|
100
|
+
if (!gateway.openclawAgent) {
|
|
101
|
+
return failResult(opts.sessionId ?? "", `openclaw-acp: gateway "${gateway.name}" did not resolve an openclawAgent (set defaultAgent on the profile or openclawAgent on the route)`);
|
|
102
|
+
}
|
|
103
|
+
const sessionKey = buildAcpSessionKey({
|
|
104
|
+
openclawAgent: gateway.openclawAgent,
|
|
105
|
+
accountId: opts.accountId,
|
|
106
|
+
// The dispatcher passes `context.conversationKey` in for routing;
|
|
107
|
+
// fall back to a stable per-accountId key when it's not present (e.g.
|
|
108
|
+
// synthetic test calls).
|
|
109
|
+
conversationKey: stringField(opts.context, "conversationKey") ?? "default",
|
|
110
|
+
});
|
|
111
|
+
const key = poolKey(opts.accountId, gateway.name);
|
|
112
|
+
let handle;
|
|
113
|
+
try {
|
|
114
|
+
handle = await this.acquireHandle(key, opts, gateway);
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
return failResult(opts.sessionId ?? "", `openclaw-acp: ${err.message}`);
|
|
118
|
+
}
|
|
119
|
+
handle.inFlight += 1;
|
|
120
|
+
if (handle.idleTimer)
|
|
121
|
+
clearTimeout(handle.idleTimer);
|
|
122
|
+
let acpSessionId = opts.sessionId ?? "";
|
|
123
|
+
let seq = 0;
|
|
124
|
+
let assistantText = "";
|
|
125
|
+
let assistantBytes = 0;
|
|
126
|
+
let capped = false;
|
|
127
|
+
let finalText = "";
|
|
128
|
+
const emitBlock = (block) => {
|
|
129
|
+
try {
|
|
130
|
+
opts.onBlock?.(block);
|
|
131
|
+
}
|
|
132
|
+
catch (err) {
|
|
133
|
+
log.warn("openclaw-acp.onBlock-threw", {
|
|
134
|
+
error: err instanceof Error ? err.message : String(err),
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
const onNotification = (note) => {
|
|
139
|
+
seq += 1;
|
|
140
|
+
// Forward raw notification as a stream block for downstream visibility.
|
|
141
|
+
const kind = classifyAcpUpdate(note);
|
|
142
|
+
emitBlock({ raw: note, kind, seq });
|
|
143
|
+
const update = note.params?.update;
|
|
144
|
+
if (update?.sessionUpdate === "agent_message_chunk") {
|
|
145
|
+
const text = extractText(update.content);
|
|
146
|
+
if (text && !capped) {
|
|
147
|
+
const bytes = Buffer.byteLength(text, "utf8");
|
|
148
|
+
if (assistantBytes + bytes > ASSISTANT_TEXT_CAP) {
|
|
149
|
+
capped = true;
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
assistantText += text;
|
|
153
|
+
assistantBytes += bytes;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
let abortListener;
|
|
159
|
+
try {
|
|
160
|
+
// Ensure we have an ACP session id. When the dispatcher doesn't carry
|
|
161
|
+
// one, ask the child to create or rebind one for our sessionKey.
|
|
162
|
+
if (!acpSessionId) {
|
|
163
|
+
try {
|
|
164
|
+
acpSessionId = await this.newSession(handle, {
|
|
165
|
+
cwd: opts.cwd,
|
|
166
|
+
sessionKey,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
catch (err) {
|
|
170
|
+
throw new Error(`newSession failed: ${err.message}`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
handle.subscribers.set(acpSessionId, onNotification);
|
|
174
|
+
if (opts.signal?.aborted) {
|
|
175
|
+
return failResult(acpSessionId, "openclaw-acp: aborted before prompt");
|
|
176
|
+
}
|
|
177
|
+
abortListener = () => {
|
|
178
|
+
// Best-effort cancel; ACP `cancel` is a notification (fire-and-forget).
|
|
179
|
+
sendNotification(handle, "session/cancel", { sessionId: acpSessionId });
|
|
180
|
+
};
|
|
181
|
+
opts.signal?.addEventListener("abort", abortListener);
|
|
182
|
+
let promptResult;
|
|
183
|
+
try {
|
|
184
|
+
promptResult = await this.prompt(handle, {
|
|
185
|
+
sessionId: acpSessionId,
|
|
186
|
+
text: opts.text,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
catch (err) {
|
|
190
|
+
const msg = err.message ?? "prompt failed";
|
|
191
|
+
// If the child says the session is gone (process restart, GC),
|
|
192
|
+
// recreate it so the next turn doesn't hard-fail.
|
|
193
|
+
if (/session not found|unknown session/i.test(msg)) {
|
|
194
|
+
try {
|
|
195
|
+
const fresh = await this.newSession(handle, {
|
|
196
|
+
cwd: opts.cwd,
|
|
197
|
+
sessionKey,
|
|
198
|
+
});
|
|
199
|
+
handle.subscribers.delete(acpSessionId);
|
|
200
|
+
acpSessionId = fresh;
|
|
201
|
+
handle.subscribers.set(acpSessionId, onNotification);
|
|
202
|
+
promptResult = await this.prompt(handle, {
|
|
203
|
+
sessionId: acpSessionId,
|
|
204
|
+
text: opts.text,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
catch (err2) {
|
|
208
|
+
throw new Error(`prompt failed after session reset: ${err2.message}`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
else {
|
|
212
|
+
throw err;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
// OpenClaw's prompt response shape isn't strictly fixed; pull a final
|
|
216
|
+
// text out of common locations and otherwise fall back to the streamed
|
|
217
|
+
// chunks accumulated above.
|
|
218
|
+
finalText = pickFinalText(promptResult) ?? assistantText;
|
|
219
|
+
if (capped) {
|
|
220
|
+
log.warn("openclaw-acp.assistant-text-capped", { sessionId: acpSessionId });
|
|
221
|
+
}
|
|
222
|
+
return {
|
|
223
|
+
text: finalText,
|
|
224
|
+
newSessionId: acpSessionId,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
catch (err) {
|
|
228
|
+
return failResult(acpSessionId, `openclaw-acp: ${err.message}`);
|
|
229
|
+
}
|
|
230
|
+
finally {
|
|
231
|
+
if (abortListener && opts.signal) {
|
|
232
|
+
try {
|
|
233
|
+
opts.signal.removeEventListener("abort", abortListener);
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
// ignore
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
handle.subscribers.delete(acpSessionId);
|
|
240
|
+
handle.inFlight = Math.max(0, handle.inFlight - 1);
|
|
241
|
+
resetIdle(handle, key);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
// ---------------------------------------------------------------------
|
|
245
|
+
// Process management
|
|
246
|
+
// ---------------------------------------------------------------------
|
|
247
|
+
async acquireHandle(key, opts, gateway) {
|
|
248
|
+
let handle = ACP_POOL.get(key);
|
|
249
|
+
if (handle && handle.closed) {
|
|
250
|
+
ACP_POOL.delete(key);
|
|
251
|
+
handle = undefined;
|
|
252
|
+
}
|
|
253
|
+
// Invalidate the cached child if its spawn args drifted from the live
|
|
254
|
+
// gateway endpoint — config reload / token rotation under the same
|
|
255
|
+
// profile name must not keep talking to the old --url / --token.
|
|
256
|
+
if (handle &&
|
|
257
|
+
(handle.spawnedUrl !== gateway.url || handle.spawnedToken !== gateway.token)) {
|
|
258
|
+
log.info("openclaw-acp.gateway-args-changed", {
|
|
259
|
+
key,
|
|
260
|
+
oldUrl: handle.spawnedUrl,
|
|
261
|
+
newUrl: gateway.url,
|
|
262
|
+
tokenChanged: handle.spawnedToken !== gateway.token,
|
|
263
|
+
});
|
|
264
|
+
shutdownHandle(handle, "gateway-args-changed");
|
|
265
|
+
ACP_POOL.delete(key);
|
|
266
|
+
handle = undefined;
|
|
267
|
+
}
|
|
268
|
+
if (!handle) {
|
|
269
|
+
handle = this.spawnAcpProcess(key, gateway);
|
|
270
|
+
ACP_POOL.set(key, handle);
|
|
271
|
+
}
|
|
272
|
+
if (!handle.initialized) {
|
|
273
|
+
if (!handle.initializePromise) {
|
|
274
|
+
handle.initializePromise = sendRequest(handle, "initialize", {
|
|
275
|
+
protocolVersion: ACP_PROTOCOL_VERSION,
|
|
276
|
+
clientCapabilities: {},
|
|
277
|
+
}).then(() => {
|
|
278
|
+
handle.initialized = true;
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
await handle.initializePromise;
|
|
282
|
+
}
|
|
283
|
+
return handle;
|
|
284
|
+
}
|
|
285
|
+
spawnAcpProcess(key, gateway) {
|
|
286
|
+
const command = resolveOpenclawCommand() ?? "openclaw";
|
|
287
|
+
const args = ["acp", "--url", gateway.url];
|
|
288
|
+
if (gateway.token)
|
|
289
|
+
args.push("--token", gateway.token);
|
|
290
|
+
const child = this.spawnFn(command, args, {
|
|
291
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
292
|
+
env: { ...process.env },
|
|
293
|
+
});
|
|
294
|
+
const handle = {
|
|
295
|
+
child,
|
|
296
|
+
pending: new Map(),
|
|
297
|
+
subscribers: new Map(),
|
|
298
|
+
nextId: 1,
|
|
299
|
+
buffer: "",
|
|
300
|
+
initialized: false,
|
|
301
|
+
inFlight: 0,
|
|
302
|
+
closed: false,
|
|
303
|
+
spawnedUrl: gateway.url,
|
|
304
|
+
spawnedToken: gateway.token,
|
|
305
|
+
};
|
|
306
|
+
child.stdout.setEncoding("utf8");
|
|
307
|
+
child.stdout.on("data", (chunk) => onStdoutChunk(handle, chunk));
|
|
308
|
+
child.stderr.setEncoding("utf8");
|
|
309
|
+
child.stderr.on("data", (chunk) => {
|
|
310
|
+
log.debug("openclaw-acp.stderr", { key, chunk: chunk.slice(0, 500) });
|
|
311
|
+
});
|
|
312
|
+
child.on("exit", (code, signal) => {
|
|
313
|
+
shutdownHandle(handle, `exit code=${code ?? "null"} signal=${signal ?? "null"}`);
|
|
314
|
+
ACP_POOL.delete(key);
|
|
315
|
+
});
|
|
316
|
+
child.on("error", (err) => {
|
|
317
|
+
log.warn("openclaw-acp.child-error", {
|
|
318
|
+
key,
|
|
319
|
+
error: err instanceof Error ? err.message : String(err),
|
|
320
|
+
});
|
|
321
|
+
shutdownHandle(handle, `error: ${err.message}`);
|
|
322
|
+
ACP_POOL.delete(key);
|
|
323
|
+
});
|
|
324
|
+
return handle;
|
|
325
|
+
}
|
|
326
|
+
async newSession(handle, args) {
|
|
327
|
+
const result = (await sendRequest(handle, "session/new", {
|
|
328
|
+
cwd: args.cwd,
|
|
329
|
+
mcpServers: [],
|
|
330
|
+
_meta: { sessionKey: args.sessionKey },
|
|
331
|
+
}));
|
|
332
|
+
if (!result?.sessionId || typeof result.sessionId !== "string") {
|
|
333
|
+
throw new Error("newSession returned no sessionId");
|
|
334
|
+
}
|
|
335
|
+
return result.sessionId;
|
|
336
|
+
}
|
|
337
|
+
async prompt(handle, args) {
|
|
338
|
+
return sendRequest(handle, "session/prompt", {
|
|
339
|
+
sessionId: args.sessionId,
|
|
340
|
+
prompt: [{ type: "text", text: args.text }],
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
// ---------------------------------------------------------------------------
|
|
345
|
+
// JSON-RPC stdio plumbing
|
|
346
|
+
// ---------------------------------------------------------------------------
|
|
347
|
+
function onStdoutChunk(handle, chunk) {
|
|
348
|
+
handle.buffer += chunk;
|
|
349
|
+
let idx;
|
|
350
|
+
while ((idx = handle.buffer.indexOf("\n")) !== -1) {
|
|
351
|
+
const line = handle.buffer.slice(0, idx).trim();
|
|
352
|
+
handle.buffer = handle.buffer.slice(idx + 1);
|
|
353
|
+
if (!line)
|
|
354
|
+
continue;
|
|
355
|
+
let msg;
|
|
356
|
+
try {
|
|
357
|
+
msg = JSON.parse(line);
|
|
358
|
+
}
|
|
359
|
+
catch (err) {
|
|
360
|
+
log.warn("openclaw-acp.parse-error", {
|
|
361
|
+
error: err instanceof Error ? err.message : String(err),
|
|
362
|
+
line: line.slice(0, 200),
|
|
363
|
+
});
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
routeMessage(handle, msg);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
function routeMessage(handle, msg) {
|
|
370
|
+
if (msg && typeof msg === "object" && "id" in msg && ("result" in msg || "error" in msg)) {
|
|
371
|
+
const id = typeof msg.id === "number" ? msg.id : Number(msg.id);
|
|
372
|
+
const pending = handle.pending.get(id);
|
|
373
|
+
if (!pending)
|
|
374
|
+
return;
|
|
375
|
+
handle.pending.delete(id);
|
|
376
|
+
if (msg.error) {
|
|
377
|
+
const message = typeof msg.error?.message === "string" ? msg.error.message : "rpc error";
|
|
378
|
+
pending.reject(new Error(message));
|
|
379
|
+
}
|
|
380
|
+
else {
|
|
381
|
+
pending.resolve(msg.result);
|
|
382
|
+
}
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
// Notification.
|
|
386
|
+
if (msg?.method && msg?.params) {
|
|
387
|
+
const sid = msg.params?.sessionId;
|
|
388
|
+
if (typeof sid === "string") {
|
|
389
|
+
const sub = handle.subscribers.get(sid);
|
|
390
|
+
if (sub) {
|
|
391
|
+
try {
|
|
392
|
+
sub({ method: msg.method, params: msg.params });
|
|
393
|
+
}
|
|
394
|
+
catch (err) {
|
|
395
|
+
log.warn("openclaw-acp.subscriber-threw", {
|
|
396
|
+
error: err instanceof Error ? err.message : String(err),
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
function sendRequest(handle, method, params) {
|
|
404
|
+
if (handle.closed)
|
|
405
|
+
return Promise.reject(new Error("acp child closed"));
|
|
406
|
+
return new Promise((resolve, reject) => {
|
|
407
|
+
const id = handle.nextId++;
|
|
408
|
+
handle.pending.set(id, { resolve, reject, method });
|
|
409
|
+
const frame = JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n";
|
|
410
|
+
try {
|
|
411
|
+
handle.child.stdin.write(frame);
|
|
412
|
+
}
|
|
413
|
+
catch (err) {
|
|
414
|
+
handle.pending.delete(id);
|
|
415
|
+
reject(err);
|
|
416
|
+
}
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
function sendNotification(handle, method, params) {
|
|
420
|
+
if (handle.closed)
|
|
421
|
+
return;
|
|
422
|
+
const frame = JSON.stringify({ jsonrpc: "2.0", method, params }) + "\n";
|
|
423
|
+
try {
|
|
424
|
+
handle.child.stdin.write(frame);
|
|
425
|
+
}
|
|
426
|
+
catch {
|
|
427
|
+
// best-effort fire-and-forget
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
// ---------------------------------------------------------------------------
|
|
431
|
+
// Helpers
|
|
432
|
+
// ---------------------------------------------------------------------------
|
|
433
|
+
function failResult(sessionId, error) {
|
|
434
|
+
return {
|
|
435
|
+
text: "",
|
|
436
|
+
newSessionId: sessionId,
|
|
437
|
+
error,
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
function classifyAcpUpdate(note) {
|
|
441
|
+
const update = note.params?.update;
|
|
442
|
+
const kind = update?.sessionUpdate;
|
|
443
|
+
switch (kind) {
|
|
444
|
+
case "agent_message_chunk":
|
|
445
|
+
return "assistant_text";
|
|
446
|
+
case "tool_call":
|
|
447
|
+
return "tool_use";
|
|
448
|
+
case "tool_call_update":
|
|
449
|
+
return "tool_result";
|
|
450
|
+
case "session_info_update":
|
|
451
|
+
case "available_commands_update":
|
|
452
|
+
case "usage_update":
|
|
453
|
+
return "system";
|
|
454
|
+
default:
|
|
455
|
+
return "other";
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
function extractText(content) {
|
|
459
|
+
if (!content)
|
|
460
|
+
return "";
|
|
461
|
+
if (typeof content === "string")
|
|
462
|
+
return content;
|
|
463
|
+
if (Array.isArray(content)) {
|
|
464
|
+
return content.map(extractText).join("");
|
|
465
|
+
}
|
|
466
|
+
if (typeof content === "object") {
|
|
467
|
+
const c = content;
|
|
468
|
+
if (typeof c.text === "string")
|
|
469
|
+
return c.text;
|
|
470
|
+
if (typeof c.content === "string")
|
|
471
|
+
return c.content;
|
|
472
|
+
if (Array.isArray(c.content))
|
|
473
|
+
return extractText(c.content);
|
|
474
|
+
}
|
|
475
|
+
return "";
|
|
476
|
+
}
|
|
477
|
+
function pickFinalText(result) {
|
|
478
|
+
if (!result || typeof result !== "object")
|
|
479
|
+
return undefined;
|
|
480
|
+
const r = result;
|
|
481
|
+
if (typeof r.text === "string" && r.text.length > 0)
|
|
482
|
+
return r.text;
|
|
483
|
+
if (typeof r.message === "string" && r.message.length > 0)
|
|
484
|
+
return r.message;
|
|
485
|
+
return undefined;
|
|
486
|
+
}
|
|
487
|
+
function stringField(bag, key) {
|
|
488
|
+
if (!bag)
|
|
489
|
+
return undefined;
|
|
490
|
+
const v = bag[key];
|
|
491
|
+
return typeof v === "string" && v.length > 0 ? v : undefined;
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* Build the OpenClaw ACP `sessionKey` for a daemon turn. `accountId` is
|
|
495
|
+
* always included to prevent two daemon agents from colliding on the same
|
|
496
|
+
* gateway-side key (RFC §3.5.2 串号 防御).
|
|
497
|
+
*/
|
|
498
|
+
export function buildAcpSessionKey(args) {
|
|
499
|
+
return `agent:${args.openclawAgent}:${args.accountId}:${args.conversationKey}`;
|
|
500
|
+
}
|
|
@@ -28,8 +28,12 @@ export interface RuntimeModule {
|
|
|
28
28
|
export declare const claudeCodeModule: RuntimeModule;
|
|
29
29
|
/** Built-in runtime module entry for Codex. */
|
|
30
30
|
export declare const codexModule: RuntimeModule;
|
|
31
|
+
/** Built-in runtime module entry for Hermes Agent (ACP stdio). */
|
|
32
|
+
export declare const hermesAgentModule: RuntimeModule;
|
|
31
33
|
/** Built-in runtime module entry for Gemini (probe-only stub). */
|
|
32
34
|
export declare const geminiModule: RuntimeModule;
|
|
35
|
+
/** Built-in runtime module entry for OpenClaw (ACP). */
|
|
36
|
+
export declare const openclawAcpModule: RuntimeModule;
|
|
33
37
|
/**
|
|
34
38
|
* Built-in runtime modules. To add a new runtime:
|
|
35
39
|
* 1. Create `runtimes/<name>.ts` extending `NdjsonStreamAdapter` (or
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { ClaudeCodeAdapter, probeClaude } from "./claude-code.js";
|
|
2
2
|
import { CodexAdapter, probeCodex } from "./codex.js";
|
|
3
3
|
import { GeminiAdapter, probeGemini } from "./gemini.js";
|
|
4
|
+
import { HermesAgentAdapter, probeHermesAgent } from "./hermes-agent.js";
|
|
5
|
+
import { OpenclawAcpAdapter, probeOpenclaw } from "./openclaw-acp.js";
|
|
4
6
|
/** Built-in runtime module entry for Claude Code. */
|
|
5
7
|
export const claudeCodeModule = {
|
|
6
8
|
id: "claude-code",
|
|
@@ -18,6 +20,15 @@ export const codexModule = {
|
|
|
18
20
|
probe: () => probeCodex(),
|
|
19
21
|
create: () => new CodexAdapter(),
|
|
20
22
|
};
|
|
23
|
+
/** Built-in runtime module entry for Hermes Agent (ACP stdio). */
|
|
24
|
+
export const hermesAgentModule = {
|
|
25
|
+
id: "hermes-agent",
|
|
26
|
+
displayName: "Hermes Agent",
|
|
27
|
+
binary: "hermes-acp",
|
|
28
|
+
envVar: "BOTCORD_HERMES_AGENT_BIN",
|
|
29
|
+
probe: () => probeHermesAgent(),
|
|
30
|
+
create: () => new HermesAgentAdapter(),
|
|
31
|
+
};
|
|
21
32
|
/** Built-in runtime module entry for Gemini (probe-only stub). */
|
|
22
33
|
export const geminiModule = {
|
|
23
34
|
id: "gemini",
|
|
@@ -27,6 +38,15 @@ export const geminiModule = {
|
|
|
27
38
|
create: () => new GeminiAdapter(),
|
|
28
39
|
supportsRun: false,
|
|
29
40
|
};
|
|
41
|
+
/** Built-in runtime module entry for OpenClaw (ACP). */
|
|
42
|
+
export const openclawAcpModule = {
|
|
43
|
+
id: "openclaw-acp",
|
|
44
|
+
displayName: "OpenClaw (ACP)",
|
|
45
|
+
binary: "openclaw",
|
|
46
|
+
envVar: "BOTCORD_OPENCLAW_BIN",
|
|
47
|
+
probe: () => probeOpenclaw(),
|
|
48
|
+
create: () => new OpenclawAcpAdapter(),
|
|
49
|
+
};
|
|
30
50
|
/**
|
|
31
51
|
* Built-in runtime modules. To add a new runtime:
|
|
32
52
|
* 1. Create `runtimes/<name>.ts` extending `NdjsonStreamAdapter` (or
|
|
@@ -36,7 +56,9 @@ export const geminiModule = {
|
|
|
36
56
|
export const RUNTIME_MODULES = [
|
|
37
57
|
claudeCodeModule,
|
|
38
58
|
codexModule,
|
|
59
|
+
hermesAgentModule,
|
|
39
60
|
geminiModule,
|
|
61
|
+
openclawAcpModule,
|
|
40
62
|
];
|
|
41
63
|
const BY_ID = new Map(RUNTIME_MODULES.map((m) => [m.id, m]));
|
|
42
64
|
/** Lookup a runtime module by id, or null when the id is unknown. */
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Convert a raw ID into a filesystem-safe path segment.
|
|
3
|
+
*
|
|
4
|
+
* Order (must not be reordered — see design §3.1):
|
|
5
|
+
* 1. obviously invalid (empty / `.` / `..` / all control/NUL)
|
|
6
|
+
* → `_invalid_<sha256-8>`
|
|
7
|
+
* 2. Windows reserved name (CON/PRN/AUX/NUL/COM1-9/LPT1-9, case-insensitive)
|
|
8
|
+
* → `_win_<raw>`
|
|
9
|
+
* 3. fast path (`^[A-Za-z0-9_-]{1,128}$`) → return raw
|
|
10
|
+
* 4. percent-encode non-whitelist bytes; truncate at 200 chars without
|
|
11
|
+
* splitting a `%XX` (191 prefix + `_` + sha256-8)
|
|
12
|
+
*
|
|
13
|
+
* The original ID is always written into the transcript record itself; this
|
|
14
|
+
* helper only sanitizes the on-disk filename.
|
|
15
|
+
*/
|
|
16
|
+
export declare function safePathSegment(raw: string): string;
|
|
17
|
+
/**
|
|
18
|
+
* Resolve the on-disk transcript file for a given (agent, room, topic). Used
|
|
19
|
+
* by the writer AND the CLI subcommands so both look at the same file.
|
|
20
|
+
*
|
|
21
|
+
* Layout (design §3.1):
|
|
22
|
+
* <rootDir>/<agentId>/transcripts/<roomId>/<topicId|_default>.jsonl
|
|
23
|
+
*
|
|
24
|
+
* Where <rootDir> is typically `~/.botcord/agents`.
|
|
25
|
+
*/
|
|
26
|
+
export declare function transcriptFilePath(rootDir: string, agentId: string, roomId: string, topicId: string | null): string;
|
|
27
|
+
/** Directory holding a (agent, room) pair's transcript files. */
|
|
28
|
+
export declare function transcriptRoomDir(rootDir: string, agentId: string, roomId: string): string;
|
|
29
|
+
/** Directory holding all transcript rooms for a single agent. */
|
|
30
|
+
export declare function transcriptAgentRoot(rootDir: string, agentId: string): string;
|