@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,606 @@
|
|
|
1
|
+
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
|
2
|
+
import {
|
|
3
|
+
readCommandVersion,
|
|
4
|
+
resolveCommandOnPath,
|
|
5
|
+
type ProbeDeps,
|
|
6
|
+
} from "./probe.js";
|
|
7
|
+
import { consoleLogger } from "../log.js";
|
|
8
|
+
import type {
|
|
9
|
+
RuntimeAdapter,
|
|
10
|
+
RuntimeProbeResult,
|
|
11
|
+
RuntimeRunOptions,
|
|
12
|
+
RuntimeRunResult,
|
|
13
|
+
StreamBlock,
|
|
14
|
+
} from "../types.js";
|
|
15
|
+
|
|
16
|
+
const log = consoleLogger;
|
|
17
|
+
|
|
18
|
+
const ACP_PROTOCOL_VERSION = 1;
|
|
19
|
+
/** How long an idle (no in-flight prompt) ACP child process is kept alive. */
|
|
20
|
+
const ACP_IDLE_TIMEOUT_MS = 5 * 60 * 1000;
|
|
21
|
+
/** Cap for streamed assistant text per turn. */
|
|
22
|
+
const ASSISTANT_TEXT_CAP = 1 * 1024 * 1024;
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Module-level process pool — survives across adapter instances. The
|
|
26
|
+
// dispatcher creates a new `OpenclawAcpAdapter` per turn (see
|
|
27
|
+
// `runtimeFactory`), so adapter-instance state cannot hold a long-lived child.
|
|
28
|
+
// Pool key includes accountId so different daemon agents never share an ACP
|
|
29
|
+
// child even when they target the same gateway profile.
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
interface AcpProcessHandle {
|
|
33
|
+
child: ChildProcessWithoutNullStreams;
|
|
34
|
+
/** Pending JSON-RPC requests keyed by id. */
|
|
35
|
+
pending: Map<number, PendingCall>;
|
|
36
|
+
/** Per-ACP-sessionId notification subscribers. */
|
|
37
|
+
subscribers: Map<string, (note: AcpNotification) => void>;
|
|
38
|
+
nextId: number;
|
|
39
|
+
buffer: string;
|
|
40
|
+
initialized: boolean;
|
|
41
|
+
initializePromise?: Promise<void>;
|
|
42
|
+
idleTimer?: NodeJS.Timeout;
|
|
43
|
+
inFlight: number;
|
|
44
|
+
closed: boolean;
|
|
45
|
+
exitReason?: string;
|
|
46
|
+
/**
|
|
47
|
+
* URL + token the child was spawned with. We compare against the live
|
|
48
|
+
* `route.gateway` on every `acquireHandle` so a config-reload/token-rotation
|
|
49
|
+
* under the same gateway name doesn't keep using a stale child.
|
|
50
|
+
*/
|
|
51
|
+
spawnedUrl: string;
|
|
52
|
+
spawnedToken: string | undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface PendingCall {
|
|
56
|
+
resolve: (value: unknown) => void;
|
|
57
|
+
reject: (err: Error) => void;
|
|
58
|
+
method: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface AcpNotification {
|
|
62
|
+
method: string;
|
|
63
|
+
params: any;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const ACP_POOL = new Map<string, AcpProcessHandle>();
|
|
67
|
+
|
|
68
|
+
function poolKey(accountId: string, gatewayName: string): string {
|
|
69
|
+
return `${accountId}::${gatewayName}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function resetIdle(h: AcpProcessHandle, key: string): void {
|
|
73
|
+
if (h.idleTimer) clearTimeout(h.idleTimer);
|
|
74
|
+
if (h.inFlight > 0) return;
|
|
75
|
+
h.idleTimer = setTimeout(() => {
|
|
76
|
+
if (h.inFlight === 0 && !h.closed) {
|
|
77
|
+
log.info("openclaw-acp.idle-timeout", { key });
|
|
78
|
+
shutdownHandle(h, "idle-timeout");
|
|
79
|
+
ACP_POOL.delete(key);
|
|
80
|
+
}
|
|
81
|
+
}, ACP_IDLE_TIMEOUT_MS);
|
|
82
|
+
h.idleTimer.unref?.();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function shutdownHandle(h: AcpProcessHandle, reason: string): void {
|
|
86
|
+
if (h.closed) return;
|
|
87
|
+
h.closed = true;
|
|
88
|
+
h.exitReason = reason;
|
|
89
|
+
if (h.idleTimer) clearTimeout(h.idleTimer);
|
|
90
|
+
for (const p of h.pending.values()) {
|
|
91
|
+
p.reject(new Error(`openclaw acp child closed: ${reason}`));
|
|
92
|
+
}
|
|
93
|
+
h.pending.clear();
|
|
94
|
+
h.subscribers.clear();
|
|
95
|
+
try {
|
|
96
|
+
h.child.kill("SIGTERM");
|
|
97
|
+
} catch {
|
|
98
|
+
// already dead
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Test-only: drop all cached child processes. */
|
|
103
|
+
export function __resetOpenclawAcpPoolForTests(): void {
|
|
104
|
+
for (const [key, h] of ACP_POOL.entries()) {
|
|
105
|
+
shutdownHandle(h, "test-reset");
|
|
106
|
+
ACP_POOL.delete(key);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
// Probe
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
function resolveOpenclawCommand(deps: ProbeDeps = {}): string | null {
|
|
115
|
+
const explicit = (deps.env ?? process.env).BOTCORD_OPENCLAW_BIN;
|
|
116
|
+
if (explicit && explicit.length > 0) return explicit;
|
|
117
|
+
return resolveCommandOnPath("openclaw", deps);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function probeOpenclaw(deps: ProbeDeps = {}): RuntimeProbeResult {
|
|
121
|
+
const command = resolveOpenclawCommand(deps);
|
|
122
|
+
if (!command) return { available: false };
|
|
123
|
+
return {
|
|
124
|
+
available: true,
|
|
125
|
+
path: command,
|
|
126
|
+
version: readCommandVersion(command, [], deps) ?? undefined,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
// Adapter
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
interface SpawnDeps {
|
|
135
|
+
spawnFn?: typeof spawn;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* OpenClaw ACP runtime adapter.
|
|
140
|
+
*
|
|
141
|
+
* Spawns `openclaw acp --url <gateway> [--token <token>]` per
|
|
142
|
+
* `(accountId, gatewayName)` pair and reuses the process across turns. The
|
|
143
|
+
* child speaks JSON-RPC over stdio; we send `initialize` once, then
|
|
144
|
+
* `newSession` (with `_meta.sessionKey`) when the daemon has no persisted
|
|
145
|
+
* runtime session id, and `prompt` for each turn. Streaming `session/update`
|
|
146
|
+
* notifications are relayed to `onBlock`.
|
|
147
|
+
*
|
|
148
|
+
* Process-pool lifetime + abort/cancel semantics live at module scope; see
|
|
149
|
+
* `ACP_POOL` and `shutdownHandle` above.
|
|
150
|
+
*/
|
|
151
|
+
export class OpenclawAcpAdapter implements RuntimeAdapter {
|
|
152
|
+
readonly id = "openclaw-acp" as const;
|
|
153
|
+
|
|
154
|
+
private readonly spawnFn: typeof spawn;
|
|
155
|
+
|
|
156
|
+
constructor(deps: SpawnDeps = {}) {
|
|
157
|
+
this.spawnFn = deps.spawnFn ?? spawn;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
probe(): RuntimeProbeResult {
|
|
161
|
+
return probeOpenclaw();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async run(opts: RuntimeRunOptions): Promise<RuntimeRunResult> {
|
|
165
|
+
const gateway = opts.gateway;
|
|
166
|
+
if (!gateway) {
|
|
167
|
+
return failResult(
|
|
168
|
+
opts.sessionId ?? "",
|
|
169
|
+
"openclaw-acp: missing gateway endpoint (route.gateway not resolved)",
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
if (!gateway.openclawAgent) {
|
|
173
|
+
return failResult(
|
|
174
|
+
opts.sessionId ?? "",
|
|
175
|
+
`openclaw-acp: gateway "${gateway.name}" did not resolve an openclawAgent (set defaultAgent on the profile or openclawAgent on the route)`,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
const sessionKey = buildAcpSessionKey({
|
|
179
|
+
openclawAgent: gateway.openclawAgent,
|
|
180
|
+
accountId: opts.accountId,
|
|
181
|
+
// The dispatcher passes `context.conversationKey` in for routing;
|
|
182
|
+
// fall back to a stable per-accountId key when it's not present (e.g.
|
|
183
|
+
// synthetic test calls).
|
|
184
|
+
conversationKey: stringField(opts.context, "conversationKey") ?? "default",
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
const key = poolKey(opts.accountId, gateway.name);
|
|
188
|
+
let handle: AcpProcessHandle;
|
|
189
|
+
try {
|
|
190
|
+
handle = await this.acquireHandle(key, opts, gateway);
|
|
191
|
+
} catch (err) {
|
|
192
|
+
return failResult(opts.sessionId ?? "", `openclaw-acp: ${(err as Error).message}`);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
handle.inFlight += 1;
|
|
196
|
+
if (handle.idleTimer) clearTimeout(handle.idleTimer);
|
|
197
|
+
|
|
198
|
+
let acpSessionId = opts.sessionId ?? "";
|
|
199
|
+
let seq = 0;
|
|
200
|
+
let assistantText = "";
|
|
201
|
+
let assistantBytes = 0;
|
|
202
|
+
let capped = false;
|
|
203
|
+
let finalText = "";
|
|
204
|
+
|
|
205
|
+
const emitBlock = (block: StreamBlock): void => {
|
|
206
|
+
try {
|
|
207
|
+
opts.onBlock?.(block);
|
|
208
|
+
} catch (err) {
|
|
209
|
+
log.warn("openclaw-acp.onBlock-threw", {
|
|
210
|
+
error: err instanceof Error ? err.message : String(err),
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const onNotification = (note: AcpNotification): void => {
|
|
216
|
+
seq += 1;
|
|
217
|
+
// Forward raw notification as a stream block for downstream visibility.
|
|
218
|
+
const kind = classifyAcpUpdate(note);
|
|
219
|
+
emitBlock({ raw: note, kind, seq });
|
|
220
|
+
|
|
221
|
+
const update = note.params?.update;
|
|
222
|
+
if (update?.sessionUpdate === "agent_message_chunk") {
|
|
223
|
+
const text = extractText(update.content);
|
|
224
|
+
if (text && !capped) {
|
|
225
|
+
const bytes = Buffer.byteLength(text, "utf8");
|
|
226
|
+
if (assistantBytes + bytes > ASSISTANT_TEXT_CAP) {
|
|
227
|
+
capped = true;
|
|
228
|
+
} else {
|
|
229
|
+
assistantText += text;
|
|
230
|
+
assistantBytes += bytes;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
let abortListener: (() => void) | undefined;
|
|
237
|
+
try {
|
|
238
|
+
// Ensure we have an ACP session id. When the dispatcher doesn't carry
|
|
239
|
+
// one, ask the child to create or rebind one for our sessionKey.
|
|
240
|
+
if (!acpSessionId) {
|
|
241
|
+
try {
|
|
242
|
+
acpSessionId = await this.newSession(handle, {
|
|
243
|
+
cwd: opts.cwd,
|
|
244
|
+
sessionKey,
|
|
245
|
+
});
|
|
246
|
+
} catch (err) {
|
|
247
|
+
throw new Error(`newSession failed: ${(err as Error).message}`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
handle.subscribers.set(acpSessionId, onNotification);
|
|
251
|
+
|
|
252
|
+
if (opts.signal?.aborted) {
|
|
253
|
+
return failResult(acpSessionId, "openclaw-acp: aborted before prompt");
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
abortListener = () => {
|
|
257
|
+
// Best-effort cancel; ACP `cancel` is a notification (fire-and-forget).
|
|
258
|
+
sendNotification(handle, "session/cancel", { sessionId: acpSessionId });
|
|
259
|
+
};
|
|
260
|
+
opts.signal?.addEventListener("abort", abortListener);
|
|
261
|
+
|
|
262
|
+
let promptResult: any;
|
|
263
|
+
try {
|
|
264
|
+
promptResult = await this.prompt(handle, {
|
|
265
|
+
sessionId: acpSessionId,
|
|
266
|
+
text: opts.text,
|
|
267
|
+
});
|
|
268
|
+
} catch (err) {
|
|
269
|
+
const msg = (err as Error).message ?? "prompt failed";
|
|
270
|
+
// If the child says the session is gone (process restart, GC),
|
|
271
|
+
// recreate it so the next turn doesn't hard-fail.
|
|
272
|
+
if (/session not found|unknown session/i.test(msg)) {
|
|
273
|
+
try {
|
|
274
|
+
const fresh = await this.newSession(handle, {
|
|
275
|
+
cwd: opts.cwd,
|
|
276
|
+
sessionKey,
|
|
277
|
+
});
|
|
278
|
+
handle.subscribers.delete(acpSessionId);
|
|
279
|
+
acpSessionId = fresh;
|
|
280
|
+
handle.subscribers.set(acpSessionId, onNotification);
|
|
281
|
+
promptResult = await this.prompt(handle, {
|
|
282
|
+
sessionId: acpSessionId,
|
|
283
|
+
text: opts.text,
|
|
284
|
+
});
|
|
285
|
+
} catch (err2) {
|
|
286
|
+
throw new Error(`prompt failed after session reset: ${(err2 as Error).message}`);
|
|
287
|
+
}
|
|
288
|
+
} else {
|
|
289
|
+
throw err;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// OpenClaw's prompt response shape isn't strictly fixed; pull a final
|
|
294
|
+
// text out of common locations and otherwise fall back to the streamed
|
|
295
|
+
// chunks accumulated above.
|
|
296
|
+
finalText = pickFinalText(promptResult) ?? assistantText;
|
|
297
|
+
|
|
298
|
+
if (capped) {
|
|
299
|
+
log.warn("openclaw-acp.assistant-text-capped", { sessionId: acpSessionId });
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return {
|
|
303
|
+
text: finalText,
|
|
304
|
+
newSessionId: acpSessionId,
|
|
305
|
+
};
|
|
306
|
+
} catch (err) {
|
|
307
|
+
return failResult(acpSessionId, `openclaw-acp: ${(err as Error).message}`);
|
|
308
|
+
} finally {
|
|
309
|
+
if (abortListener && opts.signal) {
|
|
310
|
+
try {
|
|
311
|
+
opts.signal.removeEventListener("abort", abortListener);
|
|
312
|
+
} catch {
|
|
313
|
+
// ignore
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
handle.subscribers.delete(acpSessionId);
|
|
317
|
+
handle.inFlight = Math.max(0, handle.inFlight - 1);
|
|
318
|
+
resetIdle(handle, key);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// ---------------------------------------------------------------------
|
|
323
|
+
// Process management
|
|
324
|
+
// ---------------------------------------------------------------------
|
|
325
|
+
|
|
326
|
+
private async acquireHandle(
|
|
327
|
+
key: string,
|
|
328
|
+
opts: RuntimeRunOptions,
|
|
329
|
+
gateway: NonNullable<RuntimeRunOptions["gateway"]>,
|
|
330
|
+
): Promise<AcpProcessHandle> {
|
|
331
|
+
let handle = ACP_POOL.get(key);
|
|
332
|
+
if (handle && handle.closed) {
|
|
333
|
+
ACP_POOL.delete(key);
|
|
334
|
+
handle = undefined;
|
|
335
|
+
}
|
|
336
|
+
// Invalidate the cached child if its spawn args drifted from the live
|
|
337
|
+
// gateway endpoint — config reload / token rotation under the same
|
|
338
|
+
// profile name must not keep talking to the old --url / --token.
|
|
339
|
+
if (
|
|
340
|
+
handle &&
|
|
341
|
+
(handle.spawnedUrl !== gateway.url || handle.spawnedToken !== gateway.token)
|
|
342
|
+
) {
|
|
343
|
+
log.info("openclaw-acp.gateway-args-changed", {
|
|
344
|
+
key,
|
|
345
|
+
oldUrl: handle.spawnedUrl,
|
|
346
|
+
newUrl: gateway.url,
|
|
347
|
+
tokenChanged: handle.spawnedToken !== gateway.token,
|
|
348
|
+
});
|
|
349
|
+
shutdownHandle(handle, "gateway-args-changed");
|
|
350
|
+
ACP_POOL.delete(key);
|
|
351
|
+
handle = undefined;
|
|
352
|
+
}
|
|
353
|
+
if (!handle) {
|
|
354
|
+
handle = this.spawnAcpProcess(key, gateway);
|
|
355
|
+
ACP_POOL.set(key, handle);
|
|
356
|
+
}
|
|
357
|
+
if (!handle.initialized) {
|
|
358
|
+
if (!handle.initializePromise) {
|
|
359
|
+
handle.initializePromise = sendRequest(handle, "initialize", {
|
|
360
|
+
protocolVersion: ACP_PROTOCOL_VERSION,
|
|
361
|
+
clientCapabilities: {},
|
|
362
|
+
}).then(() => {
|
|
363
|
+
handle!.initialized = true;
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
await handle.initializePromise;
|
|
367
|
+
}
|
|
368
|
+
return handle;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
private spawnAcpProcess(
|
|
372
|
+
key: string,
|
|
373
|
+
gateway: NonNullable<RuntimeRunOptions["gateway"]>,
|
|
374
|
+
): AcpProcessHandle {
|
|
375
|
+
const command = resolveOpenclawCommand() ?? "openclaw";
|
|
376
|
+
const args = ["acp", "--url", gateway.url];
|
|
377
|
+
if (gateway.token) args.push("--token", gateway.token);
|
|
378
|
+
|
|
379
|
+
const child = this.spawnFn(command, args, {
|
|
380
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
381
|
+
env: { ...process.env },
|
|
382
|
+
}) as ChildProcessWithoutNullStreams;
|
|
383
|
+
|
|
384
|
+
const handle: AcpProcessHandle = {
|
|
385
|
+
child,
|
|
386
|
+
pending: new Map(),
|
|
387
|
+
subscribers: new Map(),
|
|
388
|
+
nextId: 1,
|
|
389
|
+
buffer: "",
|
|
390
|
+
initialized: false,
|
|
391
|
+
inFlight: 0,
|
|
392
|
+
closed: false,
|
|
393
|
+
spawnedUrl: gateway.url,
|
|
394
|
+
spawnedToken: gateway.token,
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
child.stdout.setEncoding("utf8");
|
|
398
|
+
child.stdout.on("data", (chunk: string) => onStdoutChunk(handle, chunk));
|
|
399
|
+
child.stderr.setEncoding("utf8");
|
|
400
|
+
child.stderr.on("data", (chunk: string) => {
|
|
401
|
+
log.debug("openclaw-acp.stderr", { key, chunk: chunk.slice(0, 500) });
|
|
402
|
+
});
|
|
403
|
+
child.on("exit", (code, signal) => {
|
|
404
|
+
shutdownHandle(handle, `exit code=${code ?? "null"} signal=${signal ?? "null"}`);
|
|
405
|
+
ACP_POOL.delete(key);
|
|
406
|
+
});
|
|
407
|
+
child.on("error", (err) => {
|
|
408
|
+
log.warn("openclaw-acp.child-error", {
|
|
409
|
+
key,
|
|
410
|
+
error: err instanceof Error ? err.message : String(err),
|
|
411
|
+
});
|
|
412
|
+
shutdownHandle(handle, `error: ${(err as Error).message}`);
|
|
413
|
+
ACP_POOL.delete(key);
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
return handle;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
private async newSession(
|
|
420
|
+
handle: AcpProcessHandle,
|
|
421
|
+
args: { cwd: string; sessionKey: string },
|
|
422
|
+
): Promise<string> {
|
|
423
|
+
const result = (await sendRequest(handle, "session/new", {
|
|
424
|
+
cwd: args.cwd,
|
|
425
|
+
mcpServers: [],
|
|
426
|
+
_meta: { sessionKey: args.sessionKey },
|
|
427
|
+
})) as { sessionId?: string };
|
|
428
|
+
if (!result?.sessionId || typeof result.sessionId !== "string") {
|
|
429
|
+
throw new Error("newSession returned no sessionId");
|
|
430
|
+
}
|
|
431
|
+
return result.sessionId;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
private async prompt(
|
|
435
|
+
handle: AcpProcessHandle,
|
|
436
|
+
args: { sessionId: string; text: string },
|
|
437
|
+
): Promise<any> {
|
|
438
|
+
return sendRequest(handle, "session/prompt", {
|
|
439
|
+
sessionId: args.sessionId,
|
|
440
|
+
prompt: [{ type: "text", text: args.text }],
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// ---------------------------------------------------------------------------
|
|
446
|
+
// JSON-RPC stdio plumbing
|
|
447
|
+
// ---------------------------------------------------------------------------
|
|
448
|
+
|
|
449
|
+
function onStdoutChunk(handle: AcpProcessHandle, chunk: string): void {
|
|
450
|
+
handle.buffer += chunk;
|
|
451
|
+
let idx: number;
|
|
452
|
+
while ((idx = handle.buffer.indexOf("\n")) !== -1) {
|
|
453
|
+
const line = handle.buffer.slice(0, idx).trim();
|
|
454
|
+
handle.buffer = handle.buffer.slice(idx + 1);
|
|
455
|
+
if (!line) continue;
|
|
456
|
+
let msg: any;
|
|
457
|
+
try {
|
|
458
|
+
msg = JSON.parse(line);
|
|
459
|
+
} catch (err) {
|
|
460
|
+
log.warn("openclaw-acp.parse-error", {
|
|
461
|
+
error: err instanceof Error ? err.message : String(err),
|
|
462
|
+
line: line.slice(0, 200),
|
|
463
|
+
});
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
routeMessage(handle, msg);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function routeMessage(handle: AcpProcessHandle, msg: any): void {
|
|
471
|
+
if (msg && typeof msg === "object" && "id" in msg && ("result" in msg || "error" in msg)) {
|
|
472
|
+
const id = typeof msg.id === "number" ? msg.id : Number(msg.id);
|
|
473
|
+
const pending = handle.pending.get(id);
|
|
474
|
+
if (!pending) return;
|
|
475
|
+
handle.pending.delete(id);
|
|
476
|
+
if (msg.error) {
|
|
477
|
+
const message = typeof msg.error?.message === "string" ? msg.error.message : "rpc error";
|
|
478
|
+
pending.reject(new Error(message));
|
|
479
|
+
} else {
|
|
480
|
+
pending.resolve(msg.result);
|
|
481
|
+
}
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
// Notification.
|
|
485
|
+
if (msg?.method && msg?.params) {
|
|
486
|
+
const sid = msg.params?.sessionId;
|
|
487
|
+
if (typeof sid === "string") {
|
|
488
|
+
const sub = handle.subscribers.get(sid);
|
|
489
|
+
if (sub) {
|
|
490
|
+
try {
|
|
491
|
+
sub({ method: msg.method, params: msg.params });
|
|
492
|
+
} catch (err) {
|
|
493
|
+
log.warn("openclaw-acp.subscriber-threw", {
|
|
494
|
+
error: err instanceof Error ? err.message : String(err),
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function sendRequest(
|
|
503
|
+
handle: AcpProcessHandle,
|
|
504
|
+
method: string,
|
|
505
|
+
params: any,
|
|
506
|
+
): Promise<unknown> {
|
|
507
|
+
if (handle.closed) return Promise.reject(new Error("acp child closed"));
|
|
508
|
+
return new Promise((resolve, reject) => {
|
|
509
|
+
const id = handle.nextId++;
|
|
510
|
+
handle.pending.set(id, { resolve, reject, method });
|
|
511
|
+
const frame = JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n";
|
|
512
|
+
try {
|
|
513
|
+
handle.child.stdin.write(frame);
|
|
514
|
+
} catch (err) {
|
|
515
|
+
handle.pending.delete(id);
|
|
516
|
+
reject(err as Error);
|
|
517
|
+
}
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function sendNotification(
|
|
522
|
+
handle: AcpProcessHandle,
|
|
523
|
+
method: string,
|
|
524
|
+
params: any,
|
|
525
|
+
): void {
|
|
526
|
+
if (handle.closed) return;
|
|
527
|
+
const frame = JSON.stringify({ jsonrpc: "2.0", method, params }) + "\n";
|
|
528
|
+
try {
|
|
529
|
+
handle.child.stdin.write(frame);
|
|
530
|
+
} catch {
|
|
531
|
+
// best-effort fire-and-forget
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// ---------------------------------------------------------------------------
|
|
536
|
+
// Helpers
|
|
537
|
+
// ---------------------------------------------------------------------------
|
|
538
|
+
|
|
539
|
+
function failResult(sessionId: string, error: string): RuntimeRunResult {
|
|
540
|
+
return {
|
|
541
|
+
text: "",
|
|
542
|
+
newSessionId: sessionId,
|
|
543
|
+
error,
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function classifyAcpUpdate(note: AcpNotification): StreamBlock["kind"] {
|
|
548
|
+
const update = note.params?.update;
|
|
549
|
+
const kind: string | undefined = update?.sessionUpdate;
|
|
550
|
+
switch (kind) {
|
|
551
|
+
case "agent_message_chunk":
|
|
552
|
+
return "assistant_text";
|
|
553
|
+
case "tool_call":
|
|
554
|
+
return "tool_use";
|
|
555
|
+
case "tool_call_update":
|
|
556
|
+
return "tool_result";
|
|
557
|
+
case "session_info_update":
|
|
558
|
+
case "available_commands_update":
|
|
559
|
+
case "usage_update":
|
|
560
|
+
return "system";
|
|
561
|
+
default:
|
|
562
|
+
return "other";
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function extractText(content: unknown): string {
|
|
567
|
+
if (!content) return "";
|
|
568
|
+
if (typeof content === "string") return content;
|
|
569
|
+
if (Array.isArray(content)) {
|
|
570
|
+
return content.map(extractText).join("");
|
|
571
|
+
}
|
|
572
|
+
if (typeof content === "object") {
|
|
573
|
+
const c = content as Record<string, unknown>;
|
|
574
|
+
if (typeof c.text === "string") return c.text;
|
|
575
|
+
if (typeof c.content === "string") return c.content;
|
|
576
|
+
if (Array.isArray(c.content)) return extractText(c.content);
|
|
577
|
+
}
|
|
578
|
+
return "";
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function pickFinalText(result: unknown): string | undefined {
|
|
582
|
+
if (!result || typeof result !== "object") return undefined;
|
|
583
|
+
const r = result as Record<string, unknown>;
|
|
584
|
+
if (typeof r.text === "string" && r.text.length > 0) return r.text;
|
|
585
|
+
if (typeof r.message === "string" && r.message.length > 0) return r.message;
|
|
586
|
+
return undefined;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function stringField(bag: Record<string, unknown> | undefined, key: string): string | undefined {
|
|
590
|
+
if (!bag) return undefined;
|
|
591
|
+
const v = bag[key];
|
|
592
|
+
return typeof v === "string" && v.length > 0 ? v : undefined;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Build the OpenClaw ACP `sessionKey` for a daemon turn. `accountId` is
|
|
597
|
+
* always included to prevent two daemon agents from colliding on the same
|
|
598
|
+
* gateway-side key (RFC §3.5.2 串号 防御).
|
|
599
|
+
*/
|
|
600
|
+
export function buildAcpSessionKey(args: {
|
|
601
|
+
openclawAgent: string;
|
|
602
|
+
accountId: string;
|
|
603
|
+
conversationKey: string;
|
|
604
|
+
}): string {
|
|
605
|
+
return `agent:${args.openclawAgent}:${args.accountId}:${args.conversationKey}`;
|
|
606
|
+
}
|
|
@@ -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
|
import type { RuntimeAdapter, RuntimeProbeResult } from "../types.js";
|
|
5
7
|
|
|
6
8
|
/**
|
|
@@ -48,6 +50,16 @@ export const codexModule: RuntimeModule = {
|
|
|
48
50
|
create: () => new CodexAdapter(),
|
|
49
51
|
};
|
|
50
52
|
|
|
53
|
+
/** Built-in runtime module entry for Hermes Agent (ACP stdio). */
|
|
54
|
+
export const hermesAgentModule: RuntimeModule = {
|
|
55
|
+
id: "hermes-agent",
|
|
56
|
+
displayName: "Hermes Agent",
|
|
57
|
+
binary: "hermes-acp",
|
|
58
|
+
envVar: "BOTCORD_HERMES_AGENT_BIN",
|
|
59
|
+
probe: () => probeHermesAgent(),
|
|
60
|
+
create: () => new HermesAgentAdapter(),
|
|
61
|
+
};
|
|
62
|
+
|
|
51
63
|
/** Built-in runtime module entry for Gemini (probe-only stub). */
|
|
52
64
|
export const geminiModule: RuntimeModule = {
|
|
53
65
|
id: "gemini",
|
|
@@ -58,6 +70,16 @@ export const geminiModule: RuntimeModule = {
|
|
|
58
70
|
supportsRun: false,
|
|
59
71
|
};
|
|
60
72
|
|
|
73
|
+
/** Built-in runtime module entry for OpenClaw (ACP). */
|
|
74
|
+
export const openclawAcpModule: RuntimeModule = {
|
|
75
|
+
id: "openclaw-acp",
|
|
76
|
+
displayName: "OpenClaw (ACP)",
|
|
77
|
+
binary: "openclaw",
|
|
78
|
+
envVar: "BOTCORD_OPENCLAW_BIN",
|
|
79
|
+
probe: () => probeOpenclaw(),
|
|
80
|
+
create: () => new OpenclawAcpAdapter(),
|
|
81
|
+
};
|
|
82
|
+
|
|
61
83
|
/**
|
|
62
84
|
* Built-in runtime modules. To add a new runtime:
|
|
63
85
|
* 1. Create `runtimes/<name>.ts` extending `NdjsonStreamAdapter` (or
|
|
@@ -67,7 +89,9 @@ export const geminiModule: RuntimeModule = {
|
|
|
67
89
|
export const RUNTIME_MODULES: readonly RuntimeModule[] = [
|
|
68
90
|
claudeCodeModule,
|
|
69
91
|
codexModule,
|
|
92
|
+
hermesAgentModule,
|
|
70
93
|
geminiModule,
|
|
94
|
+
openclawAcpModule,
|
|
71
95
|
];
|
|
72
96
|
|
|
73
97
|
const BY_ID = new Map<string, RuntimeModule>(
|