@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,535 @@
|
|
|
1
|
+
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
|
2
|
+
import { consoleLogger } from "../log.js";
|
|
3
|
+
import type {
|
|
4
|
+
RuntimeAdapter,
|
|
5
|
+
RuntimeProbeResult,
|
|
6
|
+
RuntimeRunOptions,
|
|
7
|
+
RuntimeRunResult,
|
|
8
|
+
StreamBlock,
|
|
9
|
+
} from "../types.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Minimal bidirectional ACP (Agent Client Protocol) client used by runtime
|
|
13
|
+
* adapters whose backing CLI speaks ACP over stdio (JSON-RPC 2.0,
|
|
14
|
+
* newline-delimited).
|
|
15
|
+
*
|
|
16
|
+
* Why a base class instead of `NdjsonStreamAdapter`: ACP is a bidirectional
|
|
17
|
+
* RPC protocol — the agent sends notifications (`session/update`) AND
|
|
18
|
+
* server-initiated requests (`session/request_permission`) that the daemon
|
|
19
|
+
* MUST reply to or the agent stalls. The ndjson base only models a one-way
|
|
20
|
+
* event stream, so it cannot drive ACP correctly.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const log = consoleLogger;
|
|
24
|
+
|
|
25
|
+
/** How much stderr we keep for error reporting. */
|
|
26
|
+
const STDERR_TAIL_CAP = 8 * 1024;
|
|
27
|
+
/** How much of the retained stderr is included in synthesized errors. */
|
|
28
|
+
const STDERR_ERROR_SNIPPET = 500;
|
|
29
|
+
/** Cap on streamed assistant text per turn — guards a runaway runtime. */
|
|
30
|
+
const ASSISTANT_TEXT_CAP = 1 * 1024 * 1024;
|
|
31
|
+
/** Grace period between SIGTERM and SIGKILL on abort. */
|
|
32
|
+
const KILL_GRACE_MS = 5_000;
|
|
33
|
+
/** Deadline for the initial `initialize` handshake. */
|
|
34
|
+
const INITIALIZE_TIMEOUT_MS = 30_000;
|
|
35
|
+
/** ACP protocol version this client targets. */
|
|
36
|
+
export const ACP_PROTOCOL_VERSION = 1;
|
|
37
|
+
|
|
38
|
+
export interface AcpInitializeResult {
|
|
39
|
+
protocolVersion?: number;
|
|
40
|
+
agentInfo?: { name?: string; version?: string };
|
|
41
|
+
agentCapabilities?: Record<string, unknown>;
|
|
42
|
+
authMethods?: Array<{ id?: string; name?: string; description?: string }>;
|
|
43
|
+
[k: string]: unknown;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface AcpPermissionOption {
|
|
47
|
+
optionId: string;
|
|
48
|
+
name?: string;
|
|
49
|
+
/**
|
|
50
|
+
* ACP option kind. Common values: `allow_once`, `allow_always`,
|
|
51
|
+
* `reject_once`, `reject_always`. Treated as opaque by the base class —
|
|
52
|
+
* subclasses inspect `.kind` to pick the right outcome.
|
|
53
|
+
*/
|
|
54
|
+
kind?: string;
|
|
55
|
+
[k: string]: unknown;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface AcpPermissionRequest {
|
|
59
|
+
sessionId: string;
|
|
60
|
+
toolCall?: { name?: string; rawInput?: unknown; [k: string]: unknown };
|
|
61
|
+
options: AcpPermissionOption[];
|
|
62
|
+
[k: string]: unknown;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export type AcpPermissionResponse =
|
|
66
|
+
| { outcome: { outcome: "selected"; optionId: string } }
|
|
67
|
+
| { outcome: { outcome: "cancelled" } };
|
|
68
|
+
|
|
69
|
+
export interface AcpUpdateParams {
|
|
70
|
+
sessionId: string;
|
|
71
|
+
update: { sessionUpdate?: string; [k: string]: unknown };
|
|
72
|
+
[k: string]: unknown;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Hooks exposed to subclasses to react to inbound traffic during a turn. */
|
|
76
|
+
export interface AcpTurnHooks {
|
|
77
|
+
/** Called for each `session/update` notification. */
|
|
78
|
+
onUpdate(params: AcpUpdateParams, ctx: AcpUpdateCtx): void;
|
|
79
|
+
/** Called for `session/request_permission` requests. Must resolve to an outcome. */
|
|
80
|
+
onPermissionRequest(req: AcpPermissionRequest): Promise<AcpPermissionResponse>;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface AcpUpdateCtx {
|
|
84
|
+
/** Append to the turn's running assistant text. */
|
|
85
|
+
appendAssistantText(text: string): void;
|
|
86
|
+
/** Forward a normalized StreamBlock to `opts.onBlock`. */
|
|
87
|
+
emitBlock(block: StreamBlock): void;
|
|
88
|
+
/** 1-based sequence within this turn. */
|
|
89
|
+
seq: number;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
interface PendingCall {
|
|
93
|
+
resolve(value: unknown): void;
|
|
94
|
+
reject(err: Error): void;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Minimal newline-JSON-RPC framing on top of a child process's stdio. */
|
|
98
|
+
class AcpConnection {
|
|
99
|
+
private nextId = 1;
|
|
100
|
+
private readonly pending = new Map<number, PendingCall>();
|
|
101
|
+
private stdoutBuf = "";
|
|
102
|
+
private closed = false;
|
|
103
|
+
private closeReason: Error | null = null;
|
|
104
|
+
|
|
105
|
+
constructor(
|
|
106
|
+
private readonly child: ChildProcessWithoutNullStreams,
|
|
107
|
+
private readonly handlers: {
|
|
108
|
+
onNotification(method: string, params: unknown): void;
|
|
109
|
+
onRequest(
|
|
110
|
+
method: string,
|
|
111
|
+
params: unknown,
|
|
112
|
+
): Promise<unknown> | unknown;
|
|
113
|
+
},
|
|
114
|
+
private readonly logId: string,
|
|
115
|
+
) {
|
|
116
|
+
child.stdout.setEncoding("utf8");
|
|
117
|
+
child.stdout.on("data", (chunk: string) => this.onStdout(chunk));
|
|
118
|
+
child.stdout.on("end", () => this.fail(new Error("stdout closed")));
|
|
119
|
+
child.on("close", (code) =>
|
|
120
|
+
this.fail(new Error(`process exited with code ${code ?? 0}`)),
|
|
121
|
+
);
|
|
122
|
+
child.on("error", (err) => this.fail(err));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private onStdout(chunk: string): void {
|
|
126
|
+
this.stdoutBuf += chunk;
|
|
127
|
+
let idx: number;
|
|
128
|
+
while ((idx = this.stdoutBuf.indexOf("\n")) !== -1) {
|
|
129
|
+
const line = this.stdoutBuf.slice(0, idx).trim();
|
|
130
|
+
this.stdoutBuf = this.stdoutBuf.slice(idx + 1);
|
|
131
|
+
if (!line) continue;
|
|
132
|
+
this.dispatchLine(line);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private dispatchLine(line: string): void {
|
|
137
|
+
let msg: any;
|
|
138
|
+
try {
|
|
139
|
+
msg = JSON.parse(line);
|
|
140
|
+
} catch {
|
|
141
|
+
log.warn(`${this.logId} non-json acp line`, { line: line.slice(0, 200) });
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (typeof msg !== "object" || msg === null) return;
|
|
145
|
+
// Response to a client→server request
|
|
146
|
+
if (typeof msg.id === "number" && (msg.result !== undefined || msg.error !== undefined)) {
|
|
147
|
+
const pending = this.pending.get(msg.id);
|
|
148
|
+
if (!pending) return;
|
|
149
|
+
this.pending.delete(msg.id);
|
|
150
|
+
if (msg.error) {
|
|
151
|
+
const err = new Error(
|
|
152
|
+
`acp error ${msg.error.code ?? "?"}: ${msg.error.message ?? "(no message)"}`,
|
|
153
|
+
);
|
|
154
|
+
pending.reject(err);
|
|
155
|
+
} else {
|
|
156
|
+
pending.resolve(msg.result ?? null);
|
|
157
|
+
}
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (typeof msg.method === "string") {
|
|
161
|
+
// Server→client request (has `id`) or notification (no `id`)
|
|
162
|
+
if (msg.id !== undefined) {
|
|
163
|
+
void this.handleServerRequest(msg.id, msg.method, msg.params);
|
|
164
|
+
} else {
|
|
165
|
+
try {
|
|
166
|
+
this.handlers.onNotification(msg.method, msg.params);
|
|
167
|
+
} catch (err) {
|
|
168
|
+
log.warn(`${this.logId} notification handler threw`, {
|
|
169
|
+
method: msg.method,
|
|
170
|
+
err: String(err),
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private async handleServerRequest(
|
|
178
|
+
id: number | string,
|
|
179
|
+
method: string,
|
|
180
|
+
params: unknown,
|
|
181
|
+
): Promise<void> {
|
|
182
|
+
let result: unknown;
|
|
183
|
+
let error: { code: number; message: string } | null = null;
|
|
184
|
+
try {
|
|
185
|
+
result = await this.handlers.onRequest(method, params);
|
|
186
|
+
} catch (err) {
|
|
187
|
+
error = {
|
|
188
|
+
code: -32603,
|
|
189
|
+
message: err instanceof Error ? err.message : String(err),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
const reply = error
|
|
193
|
+
? { jsonrpc: "2.0", id, error }
|
|
194
|
+
: { jsonrpc: "2.0", id, result: result ?? null };
|
|
195
|
+
this.writeMessage(reply);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private writeMessage(obj: unknown): void {
|
|
199
|
+
if (this.closed) return;
|
|
200
|
+
try {
|
|
201
|
+
this.child.stdin.write(JSON.stringify(obj) + "\n");
|
|
202
|
+
} catch (err) {
|
|
203
|
+
this.fail(err instanceof Error ? err : new Error(String(err)));
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
request<T = unknown>(method: string, params: unknown): Promise<T> {
|
|
208
|
+
if (this.closed) {
|
|
209
|
+
return Promise.reject(this.closeReason ?? new Error("acp closed"));
|
|
210
|
+
}
|
|
211
|
+
const id = this.nextId++;
|
|
212
|
+
return new Promise<T>((resolve, reject) => {
|
|
213
|
+
this.pending.set(id, {
|
|
214
|
+
resolve: (v) => resolve(v as T),
|
|
215
|
+
reject,
|
|
216
|
+
});
|
|
217
|
+
this.writeMessage({ jsonrpc: "2.0", id, method, params });
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
notify(method: string, params: unknown): void {
|
|
222
|
+
this.writeMessage({ jsonrpc: "2.0", method, params });
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private fail(err: Error): void {
|
|
226
|
+
if (this.closed) return;
|
|
227
|
+
this.closed = true;
|
|
228
|
+
this.closeReason = err;
|
|
229
|
+
for (const [, p] of this.pending) p.reject(err);
|
|
230
|
+
this.pending.clear();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
isClosed(): boolean {
|
|
234
|
+
return this.closed;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Subclass-supplied per-turn run state, mostly here so subclasses can
|
|
239
|
+
* customize how `onUpdate` mutates the running assistant text. */
|
|
240
|
+
interface AcpRunState {
|
|
241
|
+
finalText: string;
|
|
242
|
+
assistantTextChunks: string[];
|
|
243
|
+
assistantTextBytes: number;
|
|
244
|
+
assistantTextCapped: boolean;
|
|
245
|
+
errorText?: string;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export abstract class AcpRuntimeAdapter implements RuntimeAdapter {
|
|
249
|
+
abstract readonly id: string;
|
|
250
|
+
|
|
251
|
+
probe?(): RuntimeProbeResult;
|
|
252
|
+
|
|
253
|
+
protected abstract resolveBinary(opts: RuntimeRunOptions): string;
|
|
254
|
+
/** Argv tail (excluding the binary). ACP servers usually take none. */
|
|
255
|
+
protected buildArgs(_opts: RuntimeRunOptions): string[] {
|
|
256
|
+
return [];
|
|
257
|
+
}
|
|
258
|
+
protected abstract spawnEnv(opts: RuntimeRunOptions): NodeJS.ProcessEnv;
|
|
259
|
+
/** Subclass hook: react to one `session/update` notification. */
|
|
260
|
+
protected abstract onUpdate(params: AcpUpdateParams, ctx: AcpUpdateCtx): void;
|
|
261
|
+
/** Subclass hook: respond to a `session/request_permission` request. */
|
|
262
|
+
protected abstract onPermissionRequest(
|
|
263
|
+
req: AcpPermissionRequest,
|
|
264
|
+
opts: RuntimeRunOptions,
|
|
265
|
+
): Promise<AcpPermissionResponse>;
|
|
266
|
+
|
|
267
|
+
/** Runtime-specific clientCapabilities sent on initialize. */
|
|
268
|
+
protected clientCapabilities(): Record<string, unknown> {
|
|
269
|
+
return { fs: { readTextFile: false, writeTextFile: false } };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Runtime-specific clientInfo sent on initialize. */
|
|
273
|
+
protected clientInfo(): { name: string; version: string } {
|
|
274
|
+
return { name: "botcord-daemon", version: "0.1" };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Hook invoked synchronously before spawn. Subclasses use this to write
|
|
279
|
+
* systemContext to disk (e.g. `<cwd>/AGENTS.md`).
|
|
280
|
+
*/
|
|
281
|
+
protected prepareTurn(_opts: RuntimeRunOptions): void {
|
|
282
|
+
/* default: noop */
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** cwd passed to ACP `session/new` / `session/load`. Typically `opts.cwd`. */
|
|
286
|
+
protected sessionCwd(opts: RuntimeRunOptions): string {
|
|
287
|
+
return opts.cwd;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async run(opts: RuntimeRunOptions): Promise<RuntimeRunResult> {
|
|
291
|
+
if (opts.signal.aborted) {
|
|
292
|
+
return {
|
|
293
|
+
text: "",
|
|
294
|
+
newSessionId: opts.sessionId ?? "",
|
|
295
|
+
error: `${this.id} aborted before spawn`,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
try {
|
|
300
|
+
this.prepareTurn(opts);
|
|
301
|
+
} catch (err) {
|
|
302
|
+
log.warn(`${this.id} prepareTurn threw`, { err: String(err) });
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const binary = this.resolveBinary(opts);
|
|
306
|
+
const args = this.buildArgs(opts);
|
|
307
|
+
|
|
308
|
+
log.debug(`${this.id} spawn`, {
|
|
309
|
+
cwd: opts.cwd,
|
|
310
|
+
sessionId: opts.sessionId,
|
|
311
|
+
argv: args,
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
const child = spawn(binary, args, {
|
|
315
|
+
cwd: opts.cwd,
|
|
316
|
+
env: this.spawnEnv(opts),
|
|
317
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
318
|
+
}) as ChildProcessWithoutNullStreams;
|
|
319
|
+
|
|
320
|
+
let killTimer: ReturnType<typeof setTimeout> | null = null;
|
|
321
|
+
const onAbort = () => {
|
|
322
|
+
if (child.killed) return;
|
|
323
|
+
try {
|
|
324
|
+
child.stdin.end();
|
|
325
|
+
} catch {
|
|
326
|
+
/* best-effort */
|
|
327
|
+
}
|
|
328
|
+
child.kill("SIGTERM");
|
|
329
|
+
killTimer = setTimeout(() => {
|
|
330
|
+
if (!child.killed) {
|
|
331
|
+
log.warn(`${this.id} did not exit after SIGTERM; sending SIGKILL`);
|
|
332
|
+
try {
|
|
333
|
+
child.kill("SIGKILL");
|
|
334
|
+
} catch {
|
|
335
|
+
/* best-effort */
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}, KILL_GRACE_MS);
|
|
339
|
+
if (typeof killTimer.unref === "function") killTimer.unref();
|
|
340
|
+
};
|
|
341
|
+
opts.signal.addEventListener("abort", onAbort, { once: true });
|
|
342
|
+
|
|
343
|
+
let stderrTail = "";
|
|
344
|
+
child.stderr.setEncoding("utf8");
|
|
345
|
+
child.stderr.on("data", (chunk: string) => {
|
|
346
|
+
stderrTail = (stderrTail + chunk).slice(-STDERR_TAIL_CAP);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
const state: AcpRunState = {
|
|
350
|
+
finalText: "",
|
|
351
|
+
assistantTextChunks: [],
|
|
352
|
+
assistantTextBytes: 0,
|
|
353
|
+
assistantTextCapped: false,
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
const appendAssistantText = (text: string): void => {
|
|
357
|
+
if (!text || state.assistantTextCapped) return;
|
|
358
|
+
const budget = ASSISTANT_TEXT_CAP - state.assistantTextBytes;
|
|
359
|
+
if (budget <= 0) {
|
|
360
|
+
state.assistantTextCapped = true;
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
if (text.length > budget) {
|
|
364
|
+
state.assistantTextChunks.push(text.slice(0, budget));
|
|
365
|
+
state.assistantTextBytes += budget;
|
|
366
|
+
state.assistantTextCapped = true;
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
state.assistantTextChunks.push(text);
|
|
370
|
+
state.assistantTextBytes += text.length;
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
let seq = 0;
|
|
374
|
+
|
|
375
|
+
const conn = new AcpConnection(
|
|
376
|
+
child,
|
|
377
|
+
{
|
|
378
|
+
onNotification: (method, params) => {
|
|
379
|
+
if (method === "session/update") {
|
|
380
|
+
seq += 1;
|
|
381
|
+
this.onUpdate(params as AcpUpdateParams, {
|
|
382
|
+
appendAssistantText,
|
|
383
|
+
emitBlock: (b) => opts.onBlock?.(b),
|
|
384
|
+
seq,
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
},
|
|
388
|
+
onRequest: async (method, params) => {
|
|
389
|
+
if (method === "session/request_permission") {
|
|
390
|
+
return this.onPermissionRequest(
|
|
391
|
+
params as AcpPermissionRequest,
|
|
392
|
+
opts,
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
// Unknown server→client request: signal "method not found" so the
|
|
396
|
+
// server can decide what to do. Throwing here surfaces as a JSON-RPC
|
|
397
|
+
// error reply via AcpConnection.
|
|
398
|
+
const err = new Error(`unknown server request: ${method}`);
|
|
399
|
+
throw err;
|
|
400
|
+
},
|
|
401
|
+
},
|
|
402
|
+
this.id,
|
|
403
|
+
);
|
|
404
|
+
|
|
405
|
+
const childExit = new Promise<number>((resolve) => {
|
|
406
|
+
child.on("close", (code) => resolve(code ?? 0));
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
let newSessionId = opts.sessionId ?? "";
|
|
410
|
+
|
|
411
|
+
try {
|
|
412
|
+
// 1) initialize
|
|
413
|
+
await this.withTimeout(
|
|
414
|
+
conn.request<AcpInitializeResult>("initialize", {
|
|
415
|
+
protocolVersion: ACP_PROTOCOL_VERSION,
|
|
416
|
+
clientCapabilities: this.clientCapabilities(),
|
|
417
|
+
clientInfo: this.clientInfo(),
|
|
418
|
+
}),
|
|
419
|
+
INITIALIZE_TIMEOUT_MS,
|
|
420
|
+
"initialize",
|
|
421
|
+
);
|
|
422
|
+
|
|
423
|
+
// 2) session/load (if resuming) → fallback to session/new
|
|
424
|
+
const cwd = this.sessionCwd(opts);
|
|
425
|
+
let sessionId = "";
|
|
426
|
+
if (opts.sessionId) {
|
|
427
|
+
try {
|
|
428
|
+
const loaded = (await conn.request<unknown>("session/load", {
|
|
429
|
+
sessionId: opts.sessionId,
|
|
430
|
+
cwd,
|
|
431
|
+
mcpServers: [],
|
|
432
|
+
})) as { sessionId?: string } | null;
|
|
433
|
+
if (loaded !== null && loaded !== undefined) {
|
|
434
|
+
// Hermes' load_session does NOT return a session_id — reuse the
|
|
435
|
+
// requested one. If a future server returns one, prefer it.
|
|
436
|
+
sessionId =
|
|
437
|
+
(loaded && typeof loaded.sessionId === "string"
|
|
438
|
+
? loaded.sessionId
|
|
439
|
+
: "") || opts.sessionId;
|
|
440
|
+
}
|
|
441
|
+
} catch (err) {
|
|
442
|
+
log.warn(`${this.id} session/load failed; falling back to new`, {
|
|
443
|
+
err: err instanceof Error ? err.message : String(err),
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
if (!sessionId) {
|
|
448
|
+
const created = await conn.request<{ sessionId?: string }>(
|
|
449
|
+
"session/new",
|
|
450
|
+
{ cwd, mcpServers: [] },
|
|
451
|
+
);
|
|
452
|
+
sessionId = created?.sessionId ?? "";
|
|
453
|
+
}
|
|
454
|
+
if (!sessionId) {
|
|
455
|
+
throw new Error("acp server did not return a sessionId");
|
|
456
|
+
}
|
|
457
|
+
newSessionId = sessionId;
|
|
458
|
+
|
|
459
|
+
// 3) session/prompt
|
|
460
|
+
const promptResult = (await conn.request<unknown>("session/prompt", {
|
|
461
|
+
sessionId,
|
|
462
|
+
prompt: [{ type: "text", text: opts.text }],
|
|
463
|
+
})) as { stopReason?: string } | null;
|
|
464
|
+
|
|
465
|
+
const stopReason = promptResult?.stopReason ?? "end_turn";
|
|
466
|
+
if (stopReason === "refusal" || stopReason === "error") {
|
|
467
|
+
state.errorText = state.errorText ?? `prompt stopped: ${stopReason}`;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// Politely close stdin so the server can exit. Some ACP servers shut
|
|
471
|
+
// down on EOF; if not, abort signal will SIGTERM.
|
|
472
|
+
try {
|
|
473
|
+
child.stdin.end();
|
|
474
|
+
} catch {
|
|
475
|
+
/* best-effort */
|
|
476
|
+
}
|
|
477
|
+
} catch (err) {
|
|
478
|
+
state.errorText =
|
|
479
|
+
state.errorText ??
|
|
480
|
+
(err instanceof Error ? err.message : String(err));
|
|
481
|
+
try {
|
|
482
|
+
child.stdin.end();
|
|
483
|
+
} catch {
|
|
484
|
+
/* best-effort */
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
let code = 0;
|
|
489
|
+
try {
|
|
490
|
+
code = await childExit;
|
|
491
|
+
} finally {
|
|
492
|
+
opts.signal.removeEventListener("abort", onAbort);
|
|
493
|
+
if (killTimer) clearTimeout(killTimer);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
if (code !== 0 && !state.errorText) {
|
|
497
|
+
state.errorText = `${this.id} exited with code ${code}: ${stderrTail.slice(
|
|
498
|
+
-STDERR_ERROR_SNIPPET,
|
|
499
|
+
)}`;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const rawText =
|
|
503
|
+
state.finalText || state.assistantTextChunks.join("").trim();
|
|
504
|
+
const text =
|
|
505
|
+
rawText.length > ASSISTANT_TEXT_CAP
|
|
506
|
+
? rawText.slice(0, ASSISTANT_TEXT_CAP)
|
|
507
|
+
: rawText;
|
|
508
|
+
|
|
509
|
+
return {
|
|
510
|
+
text,
|
|
511
|
+
newSessionId,
|
|
512
|
+
...(state.errorText ? { error: state.errorText } : {}),
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
private withTimeout<T>(p: Promise<T>, ms: number, label: string): Promise<T> {
|
|
517
|
+
return new Promise<T>((resolve, reject) => {
|
|
518
|
+
const t = setTimeout(
|
|
519
|
+
() => reject(new Error(`${this.id} ${label} timed out after ${ms}ms`)),
|
|
520
|
+
ms,
|
|
521
|
+
);
|
|
522
|
+
if (typeof t.unref === "function") t.unref();
|
|
523
|
+
p.then(
|
|
524
|
+
(v) => {
|
|
525
|
+
clearTimeout(t);
|
|
526
|
+
resolve(v);
|
|
527
|
+
},
|
|
528
|
+
(e) => {
|
|
529
|
+
clearTimeout(t);
|
|
530
|
+
reject(e);
|
|
531
|
+
},
|
|
532
|
+
);
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
}
|
|
@@ -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, type NdjsonEventCtx } from "./ndjson-stream.js";
|
|
6
7
|
import {
|
|
7
8
|
firstExistingPath,
|
|
@@ -214,8 +215,14 @@ export class CodexAdapter extends NdjsonStreamAdapter {
|
|
|
214
215
|
}
|
|
215
216
|
|
|
216
217
|
protected spawnEnv(opts: RuntimeRunOptions): NodeJS.ProcessEnv {
|
|
218
|
+
const cliEnv = buildCliEnv({
|
|
219
|
+
hubUrl: opts.hubUrl,
|
|
220
|
+
accountId: opts.accountId,
|
|
221
|
+
basePath: process.env.PATH,
|
|
222
|
+
});
|
|
217
223
|
const env: NodeJS.ProcessEnv = {
|
|
218
224
|
...process.env,
|
|
225
|
+
...cliEnv,
|
|
219
226
|
// Keep JSONL free of ANSI codes regardless of user terminal settings.
|
|
220
227
|
FORCE_COLOR: "0",
|
|
221
228
|
NO_COLOR: "1",
|