@botcord/daemon 0.2.4 → 0.2.6
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 +7 -3
- package/dist/agent-discovery.js +9 -1
- package/dist/agent-workspace.d.ts +62 -0
- package/dist/agent-workspace.js +140 -10
- package/dist/config.d.ts +49 -1
- package/dist/config.js +57 -1
- package/dist/control-channel.d.ts +1 -4
- package/dist/control-channel.js +1 -4
- package/dist/daemon-config-map.d.ts +29 -12
- package/dist/daemon-config-map.js +105 -8
- package/dist/daemon.d.ts +2 -0
- package/dist/daemon.js +52 -5
- 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 +66 -1
- package/dist/gateway/dispatcher.js +583 -56
- 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 +286 -27
- package/dist/mention-scan.d.ts +22 -0
- package/dist/mention-scan.js +35 -0
- package/dist/provision.d.ts +73 -3
- package/dist/provision.js +373 -12
- package/dist/system-context.d.ts +5 -4
- package/dist/system-context.js +35 -5
- package/dist/turn-text.js +20 -1
- package/dist/url-utils.d.ts +9 -0
- package/dist/url-utils.js +18 -0
- package/dist/user-auth.js +0 -2
- package/dist/working-memory.js +1 -1
- package/package.json +2 -1
- 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__/policy-resolver.test.ts +124 -0
- package/src/__tests__/policy-updated-handler.test.ts +144 -0
- package/src/__tests__/provision.test.ts +160 -0
- package/src/__tests__/system-context.test.ts +52 -0
- package/src/__tests__/url-utils.test.ts +37 -0
- package/src/agent-discovery.ts +12 -4
- package/src/agent-workspace.ts +173 -9
- package/src/config.ts +132 -4
- package/src/control-channel.ts +1 -4
- package/src/daemon-config-map.ts +156 -12
- package/src/daemon.ts +66 -5
- package/src/doctor.ts +49 -2
- package/src/gateway/__tests__/dispatcher.test.ts +440 -2
- 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 +681 -58
- 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 +295 -30
- package/src/mention-scan.ts +38 -0
- package/src/provision.ts +446 -20
- package/src/system-context.ts +41 -9
- package/src/turn-text.ts +22 -1
- package/src/url-utils.ts +17 -0
- package/src/user-auth.ts +0 -2
- package/src/working-memory.ts +1 -1
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { consoleLogger } from "../log.js";
|
|
3
|
+
/**
|
|
4
|
+
* Minimal bidirectional ACP (Agent Client Protocol) client used by runtime
|
|
5
|
+
* adapters whose backing CLI speaks ACP over stdio (JSON-RPC 2.0,
|
|
6
|
+
* newline-delimited).
|
|
7
|
+
*
|
|
8
|
+
* Why a base class instead of `NdjsonStreamAdapter`: ACP is a bidirectional
|
|
9
|
+
* RPC protocol — the agent sends notifications (`session/update`) AND
|
|
10
|
+
* server-initiated requests (`session/request_permission`) that the daemon
|
|
11
|
+
* MUST reply to or the agent stalls. The ndjson base only models a one-way
|
|
12
|
+
* event stream, so it cannot drive ACP correctly.
|
|
13
|
+
*/
|
|
14
|
+
const log = consoleLogger;
|
|
15
|
+
/** How much stderr we keep for error reporting. */
|
|
16
|
+
const STDERR_TAIL_CAP = 8 * 1024;
|
|
17
|
+
/** How much of the retained stderr is included in synthesized errors. */
|
|
18
|
+
const STDERR_ERROR_SNIPPET = 500;
|
|
19
|
+
/** Cap on streamed assistant text per turn — guards a runaway runtime. */
|
|
20
|
+
const ASSISTANT_TEXT_CAP = 1 * 1024 * 1024;
|
|
21
|
+
/** Grace period between SIGTERM and SIGKILL on abort. */
|
|
22
|
+
const KILL_GRACE_MS = 5_000;
|
|
23
|
+
/** Deadline for the initial `initialize` handshake. */
|
|
24
|
+
const INITIALIZE_TIMEOUT_MS = 30_000;
|
|
25
|
+
/** ACP protocol version this client targets. */
|
|
26
|
+
export const ACP_PROTOCOL_VERSION = 1;
|
|
27
|
+
/** Minimal newline-JSON-RPC framing on top of a child process's stdio. */
|
|
28
|
+
class AcpConnection {
|
|
29
|
+
child;
|
|
30
|
+
handlers;
|
|
31
|
+
logId;
|
|
32
|
+
nextId = 1;
|
|
33
|
+
pending = new Map();
|
|
34
|
+
stdoutBuf = "";
|
|
35
|
+
closed = false;
|
|
36
|
+
closeReason = null;
|
|
37
|
+
constructor(child, handlers, logId) {
|
|
38
|
+
this.child = child;
|
|
39
|
+
this.handlers = handlers;
|
|
40
|
+
this.logId = logId;
|
|
41
|
+
child.stdout.setEncoding("utf8");
|
|
42
|
+
child.stdout.on("data", (chunk) => this.onStdout(chunk));
|
|
43
|
+
child.stdout.on("end", () => this.fail(new Error("stdout closed")));
|
|
44
|
+
child.on("close", (code) => this.fail(new Error(`process exited with code ${code ?? 0}`)));
|
|
45
|
+
child.on("error", (err) => this.fail(err));
|
|
46
|
+
}
|
|
47
|
+
onStdout(chunk) {
|
|
48
|
+
this.stdoutBuf += chunk;
|
|
49
|
+
let idx;
|
|
50
|
+
while ((idx = this.stdoutBuf.indexOf("\n")) !== -1) {
|
|
51
|
+
const line = this.stdoutBuf.slice(0, idx).trim();
|
|
52
|
+
this.stdoutBuf = this.stdoutBuf.slice(idx + 1);
|
|
53
|
+
if (!line)
|
|
54
|
+
continue;
|
|
55
|
+
this.dispatchLine(line);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
dispatchLine(line) {
|
|
59
|
+
let msg;
|
|
60
|
+
try {
|
|
61
|
+
msg = JSON.parse(line);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
log.warn(`${this.logId} non-json acp line`, { line: line.slice(0, 200) });
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (typeof msg !== "object" || msg === null)
|
|
68
|
+
return;
|
|
69
|
+
// Response to a client→server request
|
|
70
|
+
if (typeof msg.id === "number" && (msg.result !== undefined || msg.error !== undefined)) {
|
|
71
|
+
const pending = this.pending.get(msg.id);
|
|
72
|
+
if (!pending)
|
|
73
|
+
return;
|
|
74
|
+
this.pending.delete(msg.id);
|
|
75
|
+
if (msg.error) {
|
|
76
|
+
const err = new Error(`acp error ${msg.error.code ?? "?"}: ${msg.error.message ?? "(no message)"}`);
|
|
77
|
+
pending.reject(err);
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
pending.resolve(msg.result ?? null);
|
|
81
|
+
}
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (typeof msg.method === "string") {
|
|
85
|
+
// Server→client request (has `id`) or notification (no `id`)
|
|
86
|
+
if (msg.id !== undefined) {
|
|
87
|
+
void this.handleServerRequest(msg.id, msg.method, msg.params);
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
try {
|
|
91
|
+
this.handlers.onNotification(msg.method, msg.params);
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
log.warn(`${this.logId} notification handler threw`, {
|
|
95
|
+
method: msg.method,
|
|
96
|
+
err: String(err),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
async handleServerRequest(id, method, params) {
|
|
103
|
+
let result;
|
|
104
|
+
let error = null;
|
|
105
|
+
try {
|
|
106
|
+
result = await this.handlers.onRequest(method, params);
|
|
107
|
+
}
|
|
108
|
+
catch (err) {
|
|
109
|
+
error = {
|
|
110
|
+
code: -32603,
|
|
111
|
+
message: err instanceof Error ? err.message : String(err),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
const reply = error
|
|
115
|
+
? { jsonrpc: "2.0", id, error }
|
|
116
|
+
: { jsonrpc: "2.0", id, result: result ?? null };
|
|
117
|
+
this.writeMessage(reply);
|
|
118
|
+
}
|
|
119
|
+
writeMessage(obj) {
|
|
120
|
+
if (this.closed)
|
|
121
|
+
return;
|
|
122
|
+
try {
|
|
123
|
+
this.child.stdin.write(JSON.stringify(obj) + "\n");
|
|
124
|
+
}
|
|
125
|
+
catch (err) {
|
|
126
|
+
this.fail(err instanceof Error ? err : new Error(String(err)));
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
request(method, params) {
|
|
130
|
+
if (this.closed) {
|
|
131
|
+
return Promise.reject(this.closeReason ?? new Error("acp closed"));
|
|
132
|
+
}
|
|
133
|
+
const id = this.nextId++;
|
|
134
|
+
return new Promise((resolve, reject) => {
|
|
135
|
+
this.pending.set(id, {
|
|
136
|
+
resolve: (v) => resolve(v),
|
|
137
|
+
reject,
|
|
138
|
+
});
|
|
139
|
+
this.writeMessage({ jsonrpc: "2.0", id, method, params });
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
notify(method, params) {
|
|
143
|
+
this.writeMessage({ jsonrpc: "2.0", method, params });
|
|
144
|
+
}
|
|
145
|
+
fail(err) {
|
|
146
|
+
if (this.closed)
|
|
147
|
+
return;
|
|
148
|
+
this.closed = true;
|
|
149
|
+
this.closeReason = err;
|
|
150
|
+
for (const [, p] of this.pending)
|
|
151
|
+
p.reject(err);
|
|
152
|
+
this.pending.clear();
|
|
153
|
+
}
|
|
154
|
+
isClosed() {
|
|
155
|
+
return this.closed;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
export class AcpRuntimeAdapter {
|
|
159
|
+
/** Argv tail (excluding the binary). ACP servers usually take none. */
|
|
160
|
+
buildArgs(_opts) {
|
|
161
|
+
return [];
|
|
162
|
+
}
|
|
163
|
+
/** Runtime-specific clientCapabilities sent on initialize. */
|
|
164
|
+
clientCapabilities() {
|
|
165
|
+
return { fs: { readTextFile: false, writeTextFile: false } };
|
|
166
|
+
}
|
|
167
|
+
/** Runtime-specific clientInfo sent on initialize. */
|
|
168
|
+
clientInfo() {
|
|
169
|
+
return { name: "botcord-daemon", version: "0.1" };
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Hook invoked synchronously before spawn. Subclasses use this to write
|
|
173
|
+
* systemContext to disk (e.g. `<cwd>/AGENTS.md`).
|
|
174
|
+
*/
|
|
175
|
+
prepareTurn(_opts) {
|
|
176
|
+
/* default: noop */
|
|
177
|
+
}
|
|
178
|
+
/** cwd passed to ACP `session/new` / `session/load`. Typically `opts.cwd`. */
|
|
179
|
+
sessionCwd(opts) {
|
|
180
|
+
return opts.cwd;
|
|
181
|
+
}
|
|
182
|
+
async run(opts) {
|
|
183
|
+
if (opts.signal.aborted) {
|
|
184
|
+
return {
|
|
185
|
+
text: "",
|
|
186
|
+
newSessionId: opts.sessionId ?? "",
|
|
187
|
+
error: `${this.id} aborted before spawn`,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
try {
|
|
191
|
+
this.prepareTurn(opts);
|
|
192
|
+
}
|
|
193
|
+
catch (err) {
|
|
194
|
+
log.warn(`${this.id} prepareTurn threw`, { err: String(err) });
|
|
195
|
+
}
|
|
196
|
+
const binary = this.resolveBinary(opts);
|
|
197
|
+
const args = this.buildArgs(opts);
|
|
198
|
+
log.debug(`${this.id} spawn`, {
|
|
199
|
+
cwd: opts.cwd,
|
|
200
|
+
sessionId: opts.sessionId,
|
|
201
|
+
argv: args,
|
|
202
|
+
});
|
|
203
|
+
const child = spawn(binary, args, {
|
|
204
|
+
cwd: opts.cwd,
|
|
205
|
+
env: this.spawnEnv(opts),
|
|
206
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
207
|
+
});
|
|
208
|
+
let killTimer = null;
|
|
209
|
+
const onAbort = () => {
|
|
210
|
+
if (child.killed)
|
|
211
|
+
return;
|
|
212
|
+
try {
|
|
213
|
+
child.stdin.end();
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
/* best-effort */
|
|
217
|
+
}
|
|
218
|
+
child.kill("SIGTERM");
|
|
219
|
+
killTimer = setTimeout(() => {
|
|
220
|
+
if (!child.killed) {
|
|
221
|
+
log.warn(`${this.id} did not exit after SIGTERM; sending SIGKILL`);
|
|
222
|
+
try {
|
|
223
|
+
child.kill("SIGKILL");
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
/* best-effort */
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}, KILL_GRACE_MS);
|
|
230
|
+
if (typeof killTimer.unref === "function")
|
|
231
|
+
killTimer.unref();
|
|
232
|
+
};
|
|
233
|
+
opts.signal.addEventListener("abort", onAbort, { once: true });
|
|
234
|
+
let stderrTail = "";
|
|
235
|
+
child.stderr.setEncoding("utf8");
|
|
236
|
+
child.stderr.on("data", (chunk) => {
|
|
237
|
+
stderrTail = (stderrTail + chunk).slice(-STDERR_TAIL_CAP);
|
|
238
|
+
});
|
|
239
|
+
const state = {
|
|
240
|
+
finalText: "",
|
|
241
|
+
assistantTextChunks: [],
|
|
242
|
+
assistantTextBytes: 0,
|
|
243
|
+
assistantTextCapped: false,
|
|
244
|
+
};
|
|
245
|
+
const appendAssistantText = (text) => {
|
|
246
|
+
if (!text || state.assistantTextCapped)
|
|
247
|
+
return;
|
|
248
|
+
const budget = ASSISTANT_TEXT_CAP - state.assistantTextBytes;
|
|
249
|
+
if (budget <= 0) {
|
|
250
|
+
state.assistantTextCapped = true;
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
if (text.length > budget) {
|
|
254
|
+
state.assistantTextChunks.push(text.slice(0, budget));
|
|
255
|
+
state.assistantTextBytes += budget;
|
|
256
|
+
state.assistantTextCapped = true;
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
state.assistantTextChunks.push(text);
|
|
260
|
+
state.assistantTextBytes += text.length;
|
|
261
|
+
};
|
|
262
|
+
let seq = 0;
|
|
263
|
+
const conn = new AcpConnection(child, {
|
|
264
|
+
onNotification: (method, params) => {
|
|
265
|
+
if (method === "session/update") {
|
|
266
|
+
seq += 1;
|
|
267
|
+
this.onUpdate(params, {
|
|
268
|
+
appendAssistantText,
|
|
269
|
+
emitBlock: (b) => opts.onBlock?.(b),
|
|
270
|
+
seq,
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
},
|
|
274
|
+
onRequest: async (method, params) => {
|
|
275
|
+
if (method === "session/request_permission") {
|
|
276
|
+
return this.onPermissionRequest(params, opts);
|
|
277
|
+
}
|
|
278
|
+
// Unknown server→client request: signal "method not found" so the
|
|
279
|
+
// server can decide what to do. Throwing here surfaces as a JSON-RPC
|
|
280
|
+
// error reply via AcpConnection.
|
|
281
|
+
const err = new Error(`unknown server request: ${method}`);
|
|
282
|
+
throw err;
|
|
283
|
+
},
|
|
284
|
+
}, this.id);
|
|
285
|
+
const childExit = new Promise((resolve) => {
|
|
286
|
+
child.on("close", (code) => resolve(code ?? 0));
|
|
287
|
+
});
|
|
288
|
+
let newSessionId = opts.sessionId ?? "";
|
|
289
|
+
try {
|
|
290
|
+
// 1) initialize
|
|
291
|
+
await this.withTimeout(conn.request("initialize", {
|
|
292
|
+
protocolVersion: ACP_PROTOCOL_VERSION,
|
|
293
|
+
clientCapabilities: this.clientCapabilities(),
|
|
294
|
+
clientInfo: this.clientInfo(),
|
|
295
|
+
}), INITIALIZE_TIMEOUT_MS, "initialize");
|
|
296
|
+
// 2) session/load (if resuming) → fallback to session/new
|
|
297
|
+
const cwd = this.sessionCwd(opts);
|
|
298
|
+
let sessionId = "";
|
|
299
|
+
if (opts.sessionId) {
|
|
300
|
+
try {
|
|
301
|
+
const loaded = (await conn.request("session/load", {
|
|
302
|
+
sessionId: opts.sessionId,
|
|
303
|
+
cwd,
|
|
304
|
+
mcpServers: [],
|
|
305
|
+
}));
|
|
306
|
+
if (loaded !== null && loaded !== undefined) {
|
|
307
|
+
// Hermes' load_session does NOT return a session_id — reuse the
|
|
308
|
+
// requested one. If a future server returns one, prefer it.
|
|
309
|
+
sessionId =
|
|
310
|
+
(loaded && typeof loaded.sessionId === "string"
|
|
311
|
+
? loaded.sessionId
|
|
312
|
+
: "") || opts.sessionId;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
catch (err) {
|
|
316
|
+
log.warn(`${this.id} session/load failed; falling back to new`, {
|
|
317
|
+
err: err instanceof Error ? err.message : String(err),
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
if (!sessionId) {
|
|
322
|
+
const created = await conn.request("session/new", { cwd, mcpServers: [] });
|
|
323
|
+
sessionId = created?.sessionId ?? "";
|
|
324
|
+
}
|
|
325
|
+
if (!sessionId) {
|
|
326
|
+
throw new Error("acp server did not return a sessionId");
|
|
327
|
+
}
|
|
328
|
+
newSessionId = sessionId;
|
|
329
|
+
// 3) session/prompt
|
|
330
|
+
const promptResult = (await conn.request("session/prompt", {
|
|
331
|
+
sessionId,
|
|
332
|
+
prompt: [{ type: "text", text: opts.text }],
|
|
333
|
+
}));
|
|
334
|
+
const stopReason = promptResult?.stopReason ?? "end_turn";
|
|
335
|
+
if (stopReason === "refusal" || stopReason === "error") {
|
|
336
|
+
state.errorText = state.errorText ?? `prompt stopped: ${stopReason}`;
|
|
337
|
+
}
|
|
338
|
+
// Politely close stdin so the server can exit. Some ACP servers shut
|
|
339
|
+
// down on EOF; if not, abort signal will SIGTERM.
|
|
340
|
+
try {
|
|
341
|
+
child.stdin.end();
|
|
342
|
+
}
|
|
343
|
+
catch {
|
|
344
|
+
/* best-effort */
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
catch (err) {
|
|
348
|
+
state.errorText =
|
|
349
|
+
state.errorText ??
|
|
350
|
+
(err instanceof Error ? err.message : String(err));
|
|
351
|
+
try {
|
|
352
|
+
child.stdin.end();
|
|
353
|
+
}
|
|
354
|
+
catch {
|
|
355
|
+
/* best-effort */
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
let code = 0;
|
|
359
|
+
try {
|
|
360
|
+
code = await childExit;
|
|
361
|
+
}
|
|
362
|
+
finally {
|
|
363
|
+
opts.signal.removeEventListener("abort", onAbort);
|
|
364
|
+
if (killTimer)
|
|
365
|
+
clearTimeout(killTimer);
|
|
366
|
+
}
|
|
367
|
+
if (code !== 0 && !state.errorText) {
|
|
368
|
+
state.errorText = `${this.id} exited with code ${code}: ${stderrTail.slice(-STDERR_ERROR_SNIPPET)}`;
|
|
369
|
+
}
|
|
370
|
+
const rawText = state.finalText || state.assistantTextChunks.join("").trim();
|
|
371
|
+
const text = rawText.length > ASSISTANT_TEXT_CAP
|
|
372
|
+
? rawText.slice(0, ASSISTANT_TEXT_CAP)
|
|
373
|
+
: rawText;
|
|
374
|
+
return {
|
|
375
|
+
text,
|
|
376
|
+
newSessionId,
|
|
377
|
+
...(state.errorText ? { error: state.errorText } : {}),
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
withTimeout(p, ms, label) {
|
|
381
|
+
return new Promise((resolve, reject) => {
|
|
382
|
+
const t = setTimeout(() => reject(new Error(`${this.id} ${label} timed out after ${ms}ms`)), ms);
|
|
383
|
+
if (typeof t.unref === "function")
|
|
384
|
+
t.unref();
|
|
385
|
+
p.then((v) => {
|
|
386
|
+
clearTimeout(t);
|
|
387
|
+
resolve(v);
|
|
388
|
+
}, (e) => {
|
|
389
|
+
clearTimeout(t);
|
|
390
|
+
reject(e);
|
|
391
|
+
});
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
}
|
|
@@ -2,6 +2,7 @@ import { execFileSync } from "node:child_process";
|
|
|
2
2
|
import { existsSync, mkdirSync, renameSync, writeFileSync } from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { agentCodexHomeDir, ensureAgentCodexHome } from "../../agent-workspace.js";
|
|
5
|
+
import { buildCliEnv } from "../cli-resolver.js";
|
|
5
6
|
import { NdjsonStreamAdapter } from "./ndjson-stream.js";
|
|
6
7
|
import { firstExistingPath, readCommandVersion, resolveCommandOnPath, } from "./probe.js";
|
|
7
8
|
const CODEX_DESKTOP_BUNDLE_PATH = "/Applications/Codex.app/Contents/Resources/codex";
|
|
@@ -188,8 +189,14 @@ export class CodexAdapter extends NdjsonStreamAdapter {
|
|
|
188
189
|
return ["exec", ...tail, "--", prompt];
|
|
189
190
|
}
|
|
190
191
|
spawnEnv(opts) {
|
|
192
|
+
const cliEnv = buildCliEnv({
|
|
193
|
+
hubUrl: opts.hubUrl,
|
|
194
|
+
accountId: opts.accountId,
|
|
195
|
+
basePath: process.env.PATH,
|
|
196
|
+
});
|
|
191
197
|
const env = {
|
|
192
198
|
...process.env,
|
|
199
|
+
...cliEnv,
|
|
193
200
|
// Keep JSONL free of ANSI codes regardless of user terminal settings.
|
|
194
201
|
FORCE_COLOR: "0",
|
|
195
202
|
NO_COLOR: "1",
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { AcpRuntimeAdapter, type AcpPermissionRequest, type AcpPermissionResponse, type AcpUpdateCtx, type AcpUpdateParams } from "./acp-stream.js";
|
|
2
|
+
import { type ProbeDeps } from "./probe.js";
|
|
3
|
+
import type { RuntimeProbeResult, RuntimeRunOptions } from "../types.js";
|
|
4
|
+
/** Resolve the `hermes-acp` executable on PATH. */
|
|
5
|
+
export declare function resolveHermesAcpCommand(deps?: ProbeDeps): string | null;
|
|
6
|
+
/** Probe whether `hermes-acp` is installed and report its version. */
|
|
7
|
+
export declare function probeHermesAgent(deps?: ProbeDeps): RuntimeProbeResult;
|
|
8
|
+
/**
|
|
9
|
+
* Hermes Agent adapter. Drives `hermes-acp` (the ACP stdio adapter shipped
|
|
10
|
+
* with `pip install "hermes-agent[acp]"`).
|
|
11
|
+
*
|
|
12
|
+
* ## systemContext injection
|
|
13
|
+
*
|
|
14
|
+
* Hermes discovers `AGENTS.md` from the spawn cwd upward. We point cwd at a
|
|
15
|
+
* runtime-private directory (`~/.botcord/agents/<id>/hermes-workspace/`) and
|
|
16
|
+
* write `<cwd>/AGENTS.md` from `opts.systemContext` before spawn. This is a
|
|
17
|
+
* **first-turn-only** injection: hermes persists the system prompt in the
|
|
18
|
+
* session DB and does not re-read AGENTS.md on continuation turns. The
|
|
19
|
+
* design doc tracks this as a known limitation; a follow-up PR to
|
|
20
|
+
* hermes-agent would expose a per-turn ephemeral prompt channel.
|
|
21
|
+
*
|
|
22
|
+
* ## Per-agent isolation
|
|
23
|
+
*
|
|
24
|
+
* - `HERMES_HOME` → `<agent-home>/hermes-home/` so `.env`, `state.db`,
|
|
25
|
+
* `skills/` per-agent are isolated from `~/.hermes`.
|
|
26
|
+
* - cwd → `<agent-home>/hermes-workspace/` (NOT the user-editable
|
|
27
|
+
* `<agent-home>/workspace/`) so each turn's daemon-rewritten AGENTS.md
|
|
28
|
+
* does not clobber files the user/agent edited.
|
|
29
|
+
*
|
|
30
|
+
* ## Permission policy (trustLevel → ACP outcome)
|
|
31
|
+
*
|
|
32
|
+
* `HERMES_INTERACTIVE=1` makes hermes route dangerous tool calls through the
|
|
33
|
+
* ACP `session/request_permission` reverse-call. We answer per trustLevel:
|
|
34
|
+
* - `owner` → always select an `allow_*` option
|
|
35
|
+
* - `trusted` → same; reasons go to the daemon log only
|
|
36
|
+
* - `public` → cancel (DeniedOutcome) for all writes/exec
|
|
37
|
+
*/
|
|
38
|
+
export declare class HermesAgentAdapter extends AcpRuntimeAdapter {
|
|
39
|
+
readonly id: "hermes-agent";
|
|
40
|
+
private readonly explicitBinary;
|
|
41
|
+
private resolvedBinary;
|
|
42
|
+
constructor(opts?: {
|
|
43
|
+
binary?: string;
|
|
44
|
+
});
|
|
45
|
+
probe(): RuntimeProbeResult;
|
|
46
|
+
protected resolveBinary(): string;
|
|
47
|
+
/**
|
|
48
|
+
* hermes-acp is invoked with no positional args — ACP is pure stdio
|
|
49
|
+
* JSON-RPC. We do not forward `opts.extraArgs` because hermes-acp does
|
|
50
|
+
* not accept CLI flags for runtime config; per-agent config goes in
|
|
51
|
+
* `<HERMES_HOME>/.env`.
|
|
52
|
+
*/
|
|
53
|
+
protected buildArgs(_opts: RuntimeRunOptions): string[];
|
|
54
|
+
protected spawnEnv(opts: RuntimeRunOptions): NodeJS.ProcessEnv;
|
|
55
|
+
protected sessionCwd(opts: RuntimeRunOptions): string;
|
|
56
|
+
/**
|
|
57
|
+
* Write systemContext to `<hermes-workspace>/AGENTS.md` atomically before
|
|
58
|
+
* spawn. NOTE: hermes only reads this file on the first turn of a session
|
|
59
|
+
* (see class-level docstring); subsequent turns keep the persisted
|
|
60
|
+
* system prompt and ignore filesystem changes.
|
|
61
|
+
*/
|
|
62
|
+
protected prepareTurn(opts: RuntimeRunOptions): void;
|
|
63
|
+
/** Spawn with the runtime-private hermes-workspace as cwd. */
|
|
64
|
+
run(opts: RuntimeRunOptions): Promise<import("../types.js").RuntimeRunResult>;
|
|
65
|
+
/**
|
|
66
|
+
* Translate ACP `session/update` notifications into StreamBlocks +
|
|
67
|
+
* assistant text. We surface the common shapes that hermes emits:
|
|
68
|
+
* - `agent_message_chunk` / `user_message_chunk` content blocks
|
|
69
|
+
* - `tool_call` / `tool_call_update`
|
|
70
|
+
* - `agent_thought_chunk`
|
|
71
|
+
*
|
|
72
|
+
* Anything else is forwarded as `kind: "other"` so subclasses /
|
|
73
|
+
* downstream channels can introspect.
|
|
74
|
+
*/
|
|
75
|
+
protected onUpdate(params: AcpUpdateParams, ctx: AcpUpdateCtx): void;
|
|
76
|
+
/**
|
|
77
|
+
* trustLevel-driven policy. We pick the FIRST option whose `kind` matches
|
|
78
|
+
* our intent — `allow_*` for permit, otherwise cancel. ACP's
|
|
79
|
+
* DeniedOutcome carries no `optionId` / `reason` field; rationale lives
|
|
80
|
+
* in the daemon log.
|
|
81
|
+
*/
|
|
82
|
+
protected onPermissionRequest(req: AcpPermissionRequest, opts: RuntimeRunOptions): Promise<AcpPermissionResponse>;
|
|
83
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { mkdirSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { agentHermesHomeDir, agentHermesWorkspaceDir, ensureAgentHermesWorkspace, } from "../../agent-workspace.js";
|
|
4
|
+
import { buildCliEnv } from "../cli-resolver.js";
|
|
5
|
+
import { AcpRuntimeAdapter, } from "./acp-stream.js";
|
|
6
|
+
import { readCommandVersion, resolveCommandOnPath } from "./probe.js";
|
|
7
|
+
/** Resolve the `hermes-acp` executable on PATH. */
|
|
8
|
+
export function resolveHermesAcpCommand(deps = {}) {
|
|
9
|
+
return resolveCommandOnPath("hermes-acp", deps);
|
|
10
|
+
}
|
|
11
|
+
/** Probe whether `hermes-acp` is installed and report its version. */
|
|
12
|
+
export function probeHermesAgent(deps = {}) {
|
|
13
|
+
const command = resolveHermesAcpCommand(deps);
|
|
14
|
+
if (!command)
|
|
15
|
+
return { available: false };
|
|
16
|
+
return {
|
|
17
|
+
available: true,
|
|
18
|
+
path: command,
|
|
19
|
+
version: readCommandVersion(command, [], deps) ?? undefined,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Hermes Agent adapter. Drives `hermes-acp` (the ACP stdio adapter shipped
|
|
24
|
+
* with `pip install "hermes-agent[acp]"`).
|
|
25
|
+
*
|
|
26
|
+
* ## systemContext injection
|
|
27
|
+
*
|
|
28
|
+
* Hermes discovers `AGENTS.md` from the spawn cwd upward. We point cwd at a
|
|
29
|
+
* runtime-private directory (`~/.botcord/agents/<id>/hermes-workspace/`) and
|
|
30
|
+
* write `<cwd>/AGENTS.md` from `opts.systemContext` before spawn. This is a
|
|
31
|
+
* **first-turn-only** injection: hermes persists the system prompt in the
|
|
32
|
+
* session DB and does not re-read AGENTS.md on continuation turns. The
|
|
33
|
+
* design doc tracks this as a known limitation; a follow-up PR to
|
|
34
|
+
* hermes-agent would expose a per-turn ephemeral prompt channel.
|
|
35
|
+
*
|
|
36
|
+
* ## Per-agent isolation
|
|
37
|
+
*
|
|
38
|
+
* - `HERMES_HOME` → `<agent-home>/hermes-home/` so `.env`, `state.db`,
|
|
39
|
+
* `skills/` per-agent are isolated from `~/.hermes`.
|
|
40
|
+
* - cwd → `<agent-home>/hermes-workspace/` (NOT the user-editable
|
|
41
|
+
* `<agent-home>/workspace/`) so each turn's daemon-rewritten AGENTS.md
|
|
42
|
+
* does not clobber files the user/agent edited.
|
|
43
|
+
*
|
|
44
|
+
* ## Permission policy (trustLevel → ACP outcome)
|
|
45
|
+
*
|
|
46
|
+
* `HERMES_INTERACTIVE=1` makes hermes route dangerous tool calls through the
|
|
47
|
+
* ACP `session/request_permission` reverse-call. We answer per trustLevel:
|
|
48
|
+
* - `owner` → always select an `allow_*` option
|
|
49
|
+
* - `trusted` → same; reasons go to the daemon log only
|
|
50
|
+
* - `public` → cancel (DeniedOutcome) for all writes/exec
|
|
51
|
+
*/
|
|
52
|
+
export class HermesAgentAdapter extends AcpRuntimeAdapter {
|
|
53
|
+
id = "hermes-agent";
|
|
54
|
+
explicitBinary;
|
|
55
|
+
resolvedBinary = null;
|
|
56
|
+
constructor(opts) {
|
|
57
|
+
super();
|
|
58
|
+
this.explicitBinary = opts?.binary ?? process.env.BOTCORD_HERMES_AGENT_BIN;
|
|
59
|
+
}
|
|
60
|
+
probe() {
|
|
61
|
+
return probeHermesAgent();
|
|
62
|
+
}
|
|
63
|
+
resolveBinary() {
|
|
64
|
+
if (this.explicitBinary)
|
|
65
|
+
return this.explicitBinary;
|
|
66
|
+
if (this.resolvedBinary)
|
|
67
|
+
return this.resolvedBinary;
|
|
68
|
+
this.resolvedBinary = resolveHermesAcpCommand() ?? "hermes-acp";
|
|
69
|
+
return this.resolvedBinary;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* hermes-acp is invoked with no positional args — ACP is pure stdio
|
|
73
|
+
* JSON-RPC. We do not forward `opts.extraArgs` because hermes-acp does
|
|
74
|
+
* not accept CLI flags for runtime config; per-agent config goes in
|
|
75
|
+
* `<HERMES_HOME>/.env`.
|
|
76
|
+
*/
|
|
77
|
+
buildArgs(_opts) {
|
|
78
|
+
return [];
|
|
79
|
+
}
|
|
80
|
+
spawnEnv(opts) {
|
|
81
|
+
const cliEnv = buildCliEnv({
|
|
82
|
+
hubUrl: opts.hubUrl,
|
|
83
|
+
accountId: opts.accountId,
|
|
84
|
+
basePath: process.env.PATH,
|
|
85
|
+
});
|
|
86
|
+
const env = {
|
|
87
|
+
...process.env,
|
|
88
|
+
...cliEnv,
|
|
89
|
+
// Keep ACP stdout free of ANSI codes regardless of terminal settings.
|
|
90
|
+
NO_COLOR: "1",
|
|
91
|
+
// Route dangerous tool calls through ACP request_permission.
|
|
92
|
+
HERMES_INTERACTIVE: "1",
|
|
93
|
+
};
|
|
94
|
+
if (opts.accountId) {
|
|
95
|
+
env.HERMES_HOME = agentHermesHomeDir(opts.accountId);
|
|
96
|
+
}
|
|
97
|
+
return env;
|
|
98
|
+
}
|
|
99
|
+
sessionCwd(opts) {
|
|
100
|
+
if (opts.accountId)
|
|
101
|
+
return agentHermesWorkspaceDir(opts.accountId);
|
|
102
|
+
return opts.cwd;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Write systemContext to `<hermes-workspace>/AGENTS.md` atomically before
|
|
106
|
+
* spawn. NOTE: hermes only reads this file on the first turn of a session
|
|
107
|
+
* (see class-level docstring); subsequent turns keep the persisted
|
|
108
|
+
* system prompt and ignore filesystem changes.
|
|
109
|
+
*/
|
|
110
|
+
prepareTurn(opts) {
|
|
111
|
+
if (!opts.accountId)
|
|
112
|
+
return;
|
|
113
|
+
const { hermesWorkspace } = ensureAgentHermesWorkspace(opts.accountId);
|
|
114
|
+
const target = path.join(hermesWorkspace, "AGENTS.md");
|
|
115
|
+
const tmp = path.join(hermesWorkspace, `.AGENTS.md.${process.pid}.tmp`);
|
|
116
|
+
mkdirSync(hermesWorkspace, { recursive: true, mode: 0o700 });
|
|
117
|
+
writeFileSync(tmp, opts.systemContext ?? "", { mode: 0o600 });
|
|
118
|
+
renameSync(tmp, target);
|
|
119
|
+
}
|
|
120
|
+
/** Spawn with the runtime-private hermes-workspace as cwd. */
|
|
121
|
+
async run(opts) {
|
|
122
|
+
const effective = opts.accountId
|
|
123
|
+
? { ...opts, cwd: agentHermesWorkspaceDir(opts.accountId) }
|
|
124
|
+
: opts;
|
|
125
|
+
return super.run(effective);
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Translate ACP `session/update` notifications into StreamBlocks +
|
|
129
|
+
* assistant text. We surface the common shapes that hermes emits:
|
|
130
|
+
* - `agent_message_chunk` / `user_message_chunk` content blocks
|
|
131
|
+
* - `tool_call` / `tool_call_update`
|
|
132
|
+
* - `agent_thought_chunk`
|
|
133
|
+
*
|
|
134
|
+
* Anything else is forwarded as `kind: "other"` so subclasses /
|
|
135
|
+
* downstream channels can introspect.
|
|
136
|
+
*/
|
|
137
|
+
onUpdate(params, ctx) {
|
|
138
|
+
const update = params.update ?? {};
|
|
139
|
+
const kind = typeof update.sessionUpdate === "string" ? update.sessionUpdate : "";
|
|
140
|
+
let blockKind = "other";
|
|
141
|
+
if (kind === "agent_message_chunk") {
|
|
142
|
+
const content = update
|
|
143
|
+
.content;
|
|
144
|
+
if (content && content.type === "text" && typeof content.text === "string") {
|
|
145
|
+
ctx.appendAssistantText(content.text);
|
|
146
|
+
}
|
|
147
|
+
blockKind = "assistant_text";
|
|
148
|
+
}
|
|
149
|
+
else if (kind === "agent_thought_chunk") {
|
|
150
|
+
blockKind = "system";
|
|
151
|
+
}
|
|
152
|
+
else if (kind === "tool_call" || kind === "tool_call_update") {
|
|
153
|
+
blockKind = "tool_use";
|
|
154
|
+
}
|
|
155
|
+
else if (kind === "user_message_chunk") {
|
|
156
|
+
blockKind = "other";
|
|
157
|
+
}
|
|
158
|
+
ctx.emitBlock({ raw: params, kind: blockKind, seq: ctx.seq });
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* trustLevel-driven policy. We pick the FIRST option whose `kind` matches
|
|
162
|
+
* our intent — `allow_*` for permit, otherwise cancel. ACP's
|
|
163
|
+
* DeniedOutcome carries no `optionId` / `reason` field; rationale lives
|
|
164
|
+
* in the daemon log.
|
|
165
|
+
*/
|
|
166
|
+
async onPermissionRequest(req, opts) {
|
|
167
|
+
const options = Array.isArray(req.options) ? req.options : [];
|
|
168
|
+
const trust = opts.trustLevel;
|
|
169
|
+
if (trust === "owner" || trust === "trusted") {
|
|
170
|
+
const allow = options.find((o) => typeof o.kind === "string" && o.kind.startsWith("allow_")) ??
|
|
171
|
+
options[0];
|
|
172
|
+
if (allow?.optionId) {
|
|
173
|
+
return { outcome: { outcome: "selected", optionId: allow.optionId } };
|
|
174
|
+
}
|
|
175
|
+
return { outcome: { outcome: "cancelled" } };
|
|
176
|
+
}
|
|
177
|
+
// public: deny everything that requires explicit approval
|
|
178
|
+
return { outcome: { outcome: "cancelled" } };
|
|
179
|
+
}
|
|
180
|
+
}
|