@botcord/daemon 0.2.61 → 0.2.63
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/acp-logs.d.ts +39 -0
- package/dist/acp-logs.js +333 -0
- package/dist/diagnostics.d.ts +1 -0
- package/dist/diagnostics.js +163 -1
- package/dist/gateway/dispatcher.js +32 -7
- package/dist/gateway/runtimes/acp-stream.js +114 -3
- package/dist/gateway/runtimes/openclaw-acp.js +77 -0
- package/dist/index.js +30 -24
- package/dist/openclaw-discovery.js +13 -5
- package/dist/provision.js +29 -0
- package/package.json +1 -1
- package/src/__tests__/acp-logs.test.ts +88 -0
- package/src/__tests__/diagnostics.test.ts +23 -0
- package/src/__tests__/openclaw-acp.test.ts +39 -0
- package/src/__tests__/openclaw-discovery.test.ts +1 -0
- package/src/acp-logs.ts +382 -0
- package/src/diagnostics.ts +166 -0
- package/src/gateway/__tests__/dispatcher.test.ts +26 -0
- package/src/gateway/__tests__/hermes-agent-adapter.test.ts +27 -0
- package/src/gateway/dispatcher.ts +31 -8
- package/src/gateway/runtimes/acp-stream.ts +112 -1
- package/src/gateway/runtimes/openclaw-acp.ts +76 -0
- package/src/index.ts +31 -23
- package/src/openclaw-discovery.ts +16 -5
- package/src/provision.ts +32 -1
package/src/diagnostics.ts
CHANGED
|
@@ -2,6 +2,8 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
2
2
|
import { homedir, hostname, platform, release, arch } from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { Buffer } from "node:buffer";
|
|
5
|
+
import { createRequire } from "node:module";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
5
7
|
import { deflateRawSync } from "node:zlib";
|
|
6
8
|
import {
|
|
7
9
|
AUTH_EXPIRED_FLAG_PATH,
|
|
@@ -12,11 +14,14 @@ import {
|
|
|
12
14
|
import {
|
|
13
15
|
CONFIG_FILE_PATH,
|
|
14
16
|
PID_PATH,
|
|
17
|
+
SESSIONS_PATH,
|
|
15
18
|
SNAPSHOT_PATH,
|
|
16
19
|
loadConfig,
|
|
20
|
+
saveConfig,
|
|
17
21
|
type DaemonConfig,
|
|
18
22
|
} from "./config.js";
|
|
19
23
|
import { listDaemonLogFiles, LOG_FILE_PATH, type LogFileEntry } from "./log.js";
|
|
24
|
+
import { listAcpTraceLogFiles, listRuntimeLogFiles } from "./acp-logs.js";
|
|
20
25
|
import {
|
|
21
26
|
channelsFromDaemonConfig,
|
|
22
27
|
defaultHttpFetcher,
|
|
@@ -26,16 +31,41 @@ import {
|
|
|
26
31
|
type DoctorRuntimeEntry,
|
|
27
32
|
} from "./doctor.js";
|
|
28
33
|
import { detectRuntimes } from "./adapters/runtimes.js";
|
|
34
|
+
import { log as daemonLog } from "./log.js";
|
|
35
|
+
import {
|
|
36
|
+
discoverLocalOpenclawGateways,
|
|
37
|
+
mergeOpenclawGateways,
|
|
38
|
+
openclawDiscoveryConfigEnabled,
|
|
39
|
+
} from "./openclaw-discovery.js";
|
|
29
40
|
|
|
30
41
|
const DIAGNOSTICS_DIR = path.join(homedir(), ".botcord", "diagnostics");
|
|
31
42
|
const MAX_UPLOAD_BYTES = 50 * 1024 * 1024;
|
|
32
43
|
const DEFAULT_ROTATED_LOGS_IN_BUNDLE = 5;
|
|
44
|
+
const require = createRequire(import.meta.url);
|
|
45
|
+
const MODULE_PATH = fileURLToPath(import.meta.url);
|
|
46
|
+
const ENV_ALLOWLIST = new Set([
|
|
47
|
+
"NODE_ENV",
|
|
48
|
+
"PATH",
|
|
49
|
+
"BOTCORD_HUB",
|
|
50
|
+
"BOTCORD_DAEMON_HOME",
|
|
51
|
+
"BOTCORD_DAEMON_CONFIG",
|
|
52
|
+
"BOTCORD_DAEMON_LOG",
|
|
53
|
+
"BOTCORD_DAEMON_SNAPSHOT_INTERVAL_MS",
|
|
54
|
+
"BOTCORD_HERMES_AGENT_BIN",
|
|
55
|
+
"BOTCORD_CLAUDE_CODE_BIN",
|
|
56
|
+
"BOTCORD_CODEX_BIN",
|
|
57
|
+
"BOTCORD_GEMINI_BIN",
|
|
58
|
+
"BOTCORD_DEEPSEEK_TUI_BIN",
|
|
59
|
+
"BOTCORD_KIMI_CLI_BIN",
|
|
60
|
+
"OPENCLAW_ACP_URL",
|
|
61
|
+
]);
|
|
33
62
|
|
|
34
63
|
export interface CreateDiagnosticBundleOptions {
|
|
35
64
|
diagnosticsDir?: string;
|
|
36
65
|
logFile?: string;
|
|
37
66
|
configFile?: string;
|
|
38
67
|
snapshotFile?: string;
|
|
68
|
+
sessionsFile?: string;
|
|
39
69
|
doctor?: { text: string; json: unknown };
|
|
40
70
|
includeAllLogs?: boolean;
|
|
41
71
|
}
|
|
@@ -81,6 +111,81 @@ function safeReadText(file: string): string | null {
|
|
|
81
111
|
}
|
|
82
112
|
}
|
|
83
113
|
|
|
114
|
+
function readJsonFile(file: string): Record<string, unknown> | null {
|
|
115
|
+
try {
|
|
116
|
+
const parsed = JSON.parse(readFileSync(file, "utf8")) as unknown;
|
|
117
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
118
|
+
? parsed as Record<string, unknown>
|
|
119
|
+
: null;
|
|
120
|
+
} catch {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function findDaemonPackageJson(startFile: string): Record<string, unknown> | null {
|
|
126
|
+
let dir = path.dirname(startFile);
|
|
127
|
+
for (let i = 0; i < 6; i += 1) {
|
|
128
|
+
const candidate = path.join(dir, "package.json");
|
|
129
|
+
const parsed = readJsonFile(candidate);
|
|
130
|
+
if (parsed?.name === "@botcord/daemon") return parsed;
|
|
131
|
+
const next = path.dirname(dir);
|
|
132
|
+
if (next === dir) break;
|
|
133
|
+
dir = next;
|
|
134
|
+
}
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function readInstalledPackageVersion(packageJsonSpecifier: string): string | null {
|
|
139
|
+
try {
|
|
140
|
+
const pkgPath = require.resolve(packageJsonSpecifier);
|
|
141
|
+
const parsed = readJsonFile(pkgPath);
|
|
142
|
+
return typeof parsed?.version === "string" ? parsed.version : null;
|
|
143
|
+
} catch {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function daemonRuntimeSummary(): Record<string, unknown> {
|
|
149
|
+
const pkg = findDaemonPackageJson(MODULE_PATH);
|
|
150
|
+
const version = typeof pkg?.version === "string" ? pkg.version : null;
|
|
151
|
+
const startedAtMs = Date.now() - Math.round(process.uptime() * 1000);
|
|
152
|
+
return {
|
|
153
|
+
packageName: typeof pkg?.name === "string" ? pkg.name : "@botcord/daemon",
|
|
154
|
+
version,
|
|
155
|
+
modulePath: MODULE_PATH,
|
|
156
|
+
entrypoint: process.argv[1] ?? null,
|
|
157
|
+
execPath: process.execPath,
|
|
158
|
+
argv: process.argv.map((arg) => redact(arg)),
|
|
159
|
+
execArgv: process.execArgv.map((arg) => redact(arg)),
|
|
160
|
+
cwd: process.cwd(),
|
|
161
|
+
pid: process.pid,
|
|
162
|
+
ppid: process.ppid,
|
|
163
|
+
uptimeSec: Math.round(process.uptime()),
|
|
164
|
+
startedAt: new Date(startedAtMs).toISOString(),
|
|
165
|
+
versions: {
|
|
166
|
+
node: process.version,
|
|
167
|
+
v8: process.versions.v8,
|
|
168
|
+
uv: process.versions.uv,
|
|
169
|
+
openssl: process.versions.openssl,
|
|
170
|
+
},
|
|
171
|
+
packages: {
|
|
172
|
+
"@botcord/daemon": version,
|
|
173
|
+
"@botcord/cli": readInstalledPackageVersion("@botcord/cli/package.json"),
|
|
174
|
+
"@botcord/protocol-core": readInstalledPackageVersion("@botcord/protocol-core/package.json"),
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function safeEnvironmentSummary(): Record<string, string> {
|
|
180
|
+
const out: Record<string, string> = {};
|
|
181
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
182
|
+
if (!value) continue;
|
|
183
|
+
if (!ENV_ALLOWLIST.has(key) && !key.startsWith("BOTCORD_DAEMON_")) continue;
|
|
184
|
+
out[key] = redact(value);
|
|
185
|
+
}
|
|
186
|
+
return out;
|
|
187
|
+
}
|
|
188
|
+
|
|
84
189
|
function readUserAuthSummary(): Record<string, unknown> | null {
|
|
85
190
|
const raw = safeReadText(USER_AUTH_PATH);
|
|
86
191
|
if (!raw) return null;
|
|
@@ -124,6 +229,7 @@ async function buildDoctorEntries(): Promise<{
|
|
|
124
229
|
let cfgForEndpoints: DaemonConfig | null = null;
|
|
125
230
|
try {
|
|
126
231
|
cfgForEndpoints = loadConfig();
|
|
232
|
+
cfgForEndpoints = await refreshDiscoveredOpenclawGateways(cfgForEndpoints);
|
|
127
233
|
channels = channelsFromDaemonConfig(cfgForEndpoints);
|
|
128
234
|
} catch {
|
|
129
235
|
channels = [];
|
|
@@ -147,6 +253,31 @@ async function buildDoctorEntries(): Promise<{
|
|
|
147
253
|
return { text: renderDoctor(input), json: input };
|
|
148
254
|
}
|
|
149
255
|
|
|
256
|
+
async function refreshDiscoveredOpenclawGateways(cfg: DaemonConfig): Promise<DaemonConfig> {
|
|
257
|
+
if (!openclawDiscoveryConfigEnabled(cfg)) return cfg;
|
|
258
|
+
try {
|
|
259
|
+
const found = await discoverLocalOpenclawGateways({
|
|
260
|
+
searchPaths: cfg.openclawDiscovery?.searchPaths,
|
|
261
|
+
defaultPorts: cfg.openclawDiscovery?.defaultPorts,
|
|
262
|
+
timeoutMs: 500,
|
|
263
|
+
});
|
|
264
|
+
const merged = mergeOpenclawGateways(cfg, found);
|
|
265
|
+
if (!merged.changed) return cfg;
|
|
266
|
+
saveConfig(merged.cfg);
|
|
267
|
+
daemonLog.info("openclaw discovery: gateways merged", {
|
|
268
|
+
source: "diagnostics",
|
|
269
|
+
added: merged.added.map((g) => ({ name: g.name, url: g.url })),
|
|
270
|
+
});
|
|
271
|
+
return merged.cfg;
|
|
272
|
+
} catch (err) {
|
|
273
|
+
daemonLog.warn("openclaw discovery failed; continuing", {
|
|
274
|
+
source: "diagnostics",
|
|
275
|
+
error: err instanceof Error ? err.message : String(err),
|
|
276
|
+
});
|
|
277
|
+
return cfg;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
150
281
|
function crc32(buf: Buffer): number {
|
|
151
282
|
let crc = 0xffffffff;
|
|
152
283
|
for (const b of buf) {
|
|
@@ -295,8 +426,11 @@ export async function createDiagnosticBundle(
|
|
|
295
426
|
const logFile = opts.logFile ?? LOG_FILE_PATH;
|
|
296
427
|
const configFile = opts.configFile ?? CONFIG_FILE_PATH;
|
|
297
428
|
const snapshotFile = opts.snapshotFile ?? SNAPSHOT_PATH;
|
|
429
|
+
const sessionsFile = opts.sessionsFile ?? SESSIONS_PATH;
|
|
298
430
|
const includeAllLogs = opts.includeAllLogs === true;
|
|
299
431
|
const logs = bundledLogs(logFile, includeAllLogs);
|
|
432
|
+
const acpLogs = listAcpTraceLogFiles(includeAllLogs);
|
|
433
|
+
const runtimeLogs = listRuntimeLogFiles(includeAllLogs);
|
|
300
434
|
mkdirSync(diagnosticsDir, { recursive: true, mode: 0o700 });
|
|
301
435
|
|
|
302
436
|
const doctor = opts.doctor ?? await buildDoctorEntries();
|
|
@@ -307,9 +441,12 @@ export async function createDiagnosticBundle(
|
|
|
307
441
|
release: release(),
|
|
308
442
|
arch: arch(),
|
|
309
443
|
node: process.version,
|
|
444
|
+
daemon: daemonRuntimeSummary(),
|
|
445
|
+
environment: safeEnvironmentSummary(),
|
|
310
446
|
pidPath: PID_PATH,
|
|
311
447
|
pid: process.pid,
|
|
312
448
|
configPath: configFile,
|
|
449
|
+
sessionsPath: sessionsFile,
|
|
313
450
|
snapshotPath: snapshotFile,
|
|
314
451
|
logPath: logFile,
|
|
315
452
|
logsBundled: logs.map((entry) => ({
|
|
@@ -318,6 +455,16 @@ export async function createDiagnosticBundle(
|
|
|
318
455
|
sizeBytes: entry.sizeBytes,
|
|
319
456
|
active: entry.active,
|
|
320
457
|
})),
|
|
458
|
+
acpLogsBundled: acpLogs.map((entry) => ({
|
|
459
|
+
name: entry.name,
|
|
460
|
+
path: entry.path,
|
|
461
|
+
sizeBytes: entry.sizeBytes,
|
|
462
|
+
})),
|
|
463
|
+
runtimeLogsBundled: runtimeLogs.map((entry) => ({
|
|
464
|
+
name: entry.bundleName,
|
|
465
|
+
path: entry.path,
|
|
466
|
+
sizeBytes: entry.sizeBytes,
|
|
467
|
+
})),
|
|
321
468
|
logsBundleMode: includeAllLogs ? "all" : `active_plus_${DEFAULT_ROTATED_LOGS_IN_BUNDLE}_rotated`,
|
|
322
469
|
diagnosticsDir,
|
|
323
470
|
userAuth: readUserAuthSummary(),
|
|
@@ -343,6 +490,20 @@ export async function createDiagnosticBundle(
|
|
|
343
490
|
});
|
|
344
491
|
}
|
|
345
492
|
}
|
|
493
|
+
for (const entry of acpLogs) {
|
|
494
|
+
const log = safeReadText(entry.path);
|
|
495
|
+
entries.push({
|
|
496
|
+
name: `acp-logs/${entry.name.split(path.sep).join("/")}`,
|
|
497
|
+
data: log ?? `no ACP log file at ${entry.path}\n`,
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
for (const entry of runtimeLogs) {
|
|
501
|
+
const log = safeReadText(entry.path);
|
|
502
|
+
entries.push({
|
|
503
|
+
name: entry.bundleName,
|
|
504
|
+
data: log ?? `no runtime log file at ${entry.path}\n`,
|
|
505
|
+
});
|
|
506
|
+
}
|
|
346
507
|
const config = safeReadText(configFile);
|
|
347
508
|
entries.push({
|
|
348
509
|
name: "config.json.redacted",
|
|
@@ -353,6 +514,11 @@ export async function createDiagnosticBundle(
|
|
|
353
514
|
name: "snapshot.json",
|
|
354
515
|
data: snapshot ?? `no snapshot file at ${snapshotFile}\n`,
|
|
355
516
|
});
|
|
517
|
+
const sessions = safeReadText(sessionsFile);
|
|
518
|
+
entries.push({
|
|
519
|
+
name: "sessions.json.redacted",
|
|
520
|
+
data: sessions ?? `no sessions file at ${sessionsFile}\n`,
|
|
521
|
+
});
|
|
356
522
|
|
|
357
523
|
const zip = createZip(entries);
|
|
358
524
|
const out = path.join(diagnosticsDir, filename);
|
|
@@ -344,6 +344,32 @@ describe("Dispatcher", () => {
|
|
|
344
344
|
expect(store.all().length).toBe(0);
|
|
345
345
|
});
|
|
346
346
|
|
|
347
|
+
it("drops the stored session when a resumed turn errors without text even if the adapter returns the same id", async () => {
|
|
348
|
+
let callNo = 0;
|
|
349
|
+
const runtimeFactory: RuntimeFactory = () => {
|
|
350
|
+
callNo += 1;
|
|
351
|
+
if (callNo === 1) return new FakeRuntime({ reply: "ok", newSessionId: "sid-1" });
|
|
352
|
+
return new FakeRuntime({
|
|
353
|
+
reply: "",
|
|
354
|
+
newSessionId: "sid-1",
|
|
355
|
+
errorText: "acp error -32603: Internal error",
|
|
356
|
+
});
|
|
357
|
+
};
|
|
358
|
+
const { dispatcher, store, channel } = await scaffold({ runtimeFactory });
|
|
359
|
+
|
|
360
|
+
await dispatcher.handle(
|
|
361
|
+
makeEnvelope({ id: "msg_1", conversation: { id: "rm_x", kind: "direct" } }),
|
|
362
|
+
);
|
|
363
|
+
expect(store.all()[0].runtimeSessionId).toBe("sid-1");
|
|
364
|
+
|
|
365
|
+
await dispatcher.handle(
|
|
366
|
+
makeEnvelope({ id: "msg_2", conversation: { id: "rm_x", kind: "direct" } }),
|
|
367
|
+
);
|
|
368
|
+
|
|
369
|
+
expect(store.all().length).toBe(0);
|
|
370
|
+
expect(channel.sends[0].message.type).toBe("error");
|
|
371
|
+
});
|
|
372
|
+
|
|
347
373
|
it("applies composeUserTurn before handing text to the runtime", async () => {
|
|
348
374
|
const runtime = new FakeRuntime({ reply: "ok", newSessionId: "sid-1" });
|
|
349
375
|
const { store, dir } = await makeStore();
|
|
@@ -183,6 +183,33 @@ describe("HermesAgentAdapter", () => {
|
|
|
183
183
|
expect(res.error).toBeUndefined();
|
|
184
184
|
});
|
|
185
185
|
|
|
186
|
+
it("drains late assistant text after a prompt RPC error before closing stdin", async () => {
|
|
187
|
+
const script = makeAcpServer(
|
|
188
|
+
"late-after-error.js",
|
|
189
|
+
`
|
|
190
|
+
if (msg.method === "initialize") {
|
|
191
|
+
reply(msg, { protocolVersion: 1 });
|
|
192
|
+
} else if (msg.method === "session/new") {
|
|
193
|
+
reply(msg, { sessionId: "sess-late-error" });
|
|
194
|
+
} else if (msg.method === "session/prompt") {
|
|
195
|
+
err(msg, -32603, "Internal error");
|
|
196
|
+
setTimeout(() => {
|
|
197
|
+
notify("session/update", {
|
|
198
|
+
sessionId: msg.params.sessionId,
|
|
199
|
+
update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "late but valid" } }
|
|
200
|
+
});
|
|
201
|
+
process.exit(0);
|
|
202
|
+
}, 25);
|
|
203
|
+
}
|
|
204
|
+
`,
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
const res = await runAdapter(script);
|
|
208
|
+
expect(res.newSessionId).toBe("sess-late-error");
|
|
209
|
+
expect(res.text).toBe("late but valid");
|
|
210
|
+
expect(res.error).toContain("acp error -32603");
|
|
211
|
+
});
|
|
212
|
+
|
|
186
213
|
it("owner trust → request_permission selects an allow_* option", async () => {
|
|
187
214
|
const script = makeAcpServer(
|
|
188
215
|
"perm-allow.js",
|
|
@@ -1200,6 +1200,14 @@ export class Dispatcher {
|
|
|
1200
1200
|
systemContext,
|
|
1201
1201
|
onBlock,
|
|
1202
1202
|
onStatus,
|
|
1203
|
+
context: {
|
|
1204
|
+
turnId,
|
|
1205
|
+
messageId: msg.id,
|
|
1206
|
+
roomId: msg.conversation.id,
|
|
1207
|
+
topicId: msg.conversation.threadId ?? null,
|
|
1208
|
+
channel: msg.channel,
|
|
1209
|
+
conversationKind: msg.conversation.kind,
|
|
1210
|
+
},
|
|
1203
1211
|
gateway: route.gateway,
|
|
1204
1212
|
...(route.hermesProfile ? { hermesProfile: route.hermesProfile } : {}),
|
|
1205
1213
|
});
|
|
@@ -1329,16 +1337,34 @@ export class Dispatcher {
|
|
|
1329
1337
|
|
|
1330
1338
|
if (!result) return;
|
|
1331
1339
|
|
|
1340
|
+
const replyText = (result.text || "").trim();
|
|
1341
|
+
const finalTextField = truncateTextField(result.text || "");
|
|
1342
|
+
|
|
1332
1343
|
// Persist session before reply so next turn sees the new id even if send fails.
|
|
1333
1344
|
//
|
|
1334
1345
|
// Adapter contract:
|
|
1335
|
-
// result.
|
|
1336
|
-
//
|
|
1337
|
-
// → the prior session is dead (e.g. Claude Code
|
|
1338
|
-
// "--resume <missing-uuid>"); delete the entry so
|
|
1346
|
+
// had-inbound-sessionId + result.error + no reply text
|
|
1347
|
+
// → the prior session is suspect/dead; delete it so
|
|
1339
1348
|
// we don't keep resuming a stale id every turn
|
|
1349
|
+
// even when the adapter echoes that id back
|
|
1350
|
+
// result.newSessionId truthy → upsert the entry
|
|
1340
1351
|
// otherwise → no-op (e.g. codex intentionally never persists)
|
|
1341
|
-
if (result.
|
|
1352
|
+
if (sessionId && result.error && !replyText) {
|
|
1353
|
+
try {
|
|
1354
|
+
await this.sessionStore.delete(key);
|
|
1355
|
+
this.log.info("dispatcher: dropped stale runtime session", {
|
|
1356
|
+
key,
|
|
1357
|
+
prevRuntimeSessionId: sessionId,
|
|
1358
|
+
nextRuntimeSessionId: result.newSessionId || null,
|
|
1359
|
+
error: result.error,
|
|
1360
|
+
});
|
|
1361
|
+
} catch (err) {
|
|
1362
|
+
this.log.warn("dispatcher: session-store.delete failed", {
|
|
1363
|
+
key,
|
|
1364
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1365
|
+
});
|
|
1366
|
+
}
|
|
1367
|
+
} else if (result.newSessionId) {
|
|
1342
1368
|
const session: GatewaySessionEntry = {
|
|
1343
1369
|
key,
|
|
1344
1370
|
runtime: route.runtime,
|
|
@@ -1381,9 +1407,6 @@ export class Dispatcher {
|
|
|
1381
1407
|
}
|
|
1382
1408
|
}
|
|
1383
1409
|
|
|
1384
|
-
const replyText = (result.text || "").trim();
|
|
1385
|
-
const finalTextField = truncateTextField(result.text || "");
|
|
1386
|
-
|
|
1387
1410
|
if (!replyText) {
|
|
1388
1411
|
if (result.error) {
|
|
1389
1412
|
this.log.warn("dispatcher: runtime returned error without reply text", {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
|
2
|
+
import { createAcpTraceLogger, type AcpTraceLogger } from "../../acp-logs.js";
|
|
2
3
|
import { consoleLogger } from "../log.js";
|
|
3
4
|
import type {
|
|
4
5
|
RuntimeAdapter,
|
|
@@ -33,9 +34,17 @@ const ASSISTANT_TEXT_CAP = 1 * 1024 * 1024;
|
|
|
33
34
|
const KILL_GRACE_MS = 5_000;
|
|
34
35
|
/** Deadline for the initial `initialize` handshake. */
|
|
35
36
|
const INITIALIZE_TIMEOUT_MS = 30_000;
|
|
37
|
+
/** Short drain window for late `session/update` chunks after a prompt RPC error. */
|
|
38
|
+
const PROMPT_ERROR_DRAIN_MS = 750;
|
|
36
39
|
/** ACP protocol version this client targets. */
|
|
37
40
|
export const ACP_PROTOCOL_VERSION = 1;
|
|
38
41
|
|
|
42
|
+
function stringField(obj: unknown, key: string): string | undefined {
|
|
43
|
+
if (!obj || typeof obj !== "object") return undefined;
|
|
44
|
+
const value = (obj as Record<string, unknown>)[key];
|
|
45
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
39
48
|
export interface AcpInitializeResult {
|
|
40
49
|
protocolVersion?: number;
|
|
41
50
|
agentInfo?: { name?: string; version?: string };
|
|
@@ -119,6 +128,7 @@ class AcpConnection {
|
|
|
119
128
|
): Promise<unknown> | unknown;
|
|
120
129
|
},
|
|
121
130
|
private readonly logId: string,
|
|
131
|
+
private readonly trace: AcpTraceLogger | null = null,
|
|
122
132
|
) {
|
|
123
133
|
child.stdout.setEncoding("utf8");
|
|
124
134
|
child.stdout.on("data", (chunk: string) => this.onStdout(chunk));
|
|
@@ -142,9 +152,11 @@ class AcpConnection {
|
|
|
142
152
|
|
|
143
153
|
private dispatchLine(line: string): void {
|
|
144
154
|
let msg: any;
|
|
155
|
+
|
|
145
156
|
try {
|
|
146
157
|
msg = JSON.parse(line);
|
|
147
158
|
} catch {
|
|
159
|
+
this.trace?.write({ stream: "stdout_non_json", chunk: line });
|
|
148
160
|
log.warn(`${this.logId} non-json acp line`, { line: line.slice(0, 200) });
|
|
149
161
|
return;
|
|
150
162
|
}
|
|
@@ -155,11 +167,26 @@ class AcpConnection {
|
|
|
155
167
|
if (!pending) return;
|
|
156
168
|
this.pending.delete(msg.id);
|
|
157
169
|
if (msg.error) {
|
|
170
|
+
this.trace?.write({
|
|
171
|
+
stream: "rpc_in",
|
|
172
|
+
direction: "in",
|
|
173
|
+
id: msg.id,
|
|
174
|
+
status: "error",
|
|
175
|
+
code: typeof msg.error.code === "number" ? msg.error.code : undefined,
|
|
176
|
+
error: msg.error.message ?? "(no message)",
|
|
177
|
+
});
|
|
158
178
|
const err = new Error(
|
|
159
179
|
`acp error ${msg.error.code ?? "?"}: ${msg.error.message ?? "(no message)"}`,
|
|
160
180
|
);
|
|
161
181
|
pending.reject(err);
|
|
162
182
|
} else {
|
|
183
|
+
this.trace?.write({
|
|
184
|
+
stream: "rpc_in",
|
|
185
|
+
direction: "in",
|
|
186
|
+
id: msg.id,
|
|
187
|
+
status: "response",
|
|
188
|
+
result: msg.result ?? null,
|
|
189
|
+
});
|
|
163
190
|
pending.resolve(msg.result ?? null);
|
|
164
191
|
}
|
|
165
192
|
return;
|
|
@@ -167,8 +194,23 @@ class AcpConnection {
|
|
|
167
194
|
if (typeof msg.method === "string") {
|
|
168
195
|
// Server→client request (has `id`) or notification (no `id`)
|
|
169
196
|
if (msg.id !== undefined) {
|
|
197
|
+
this.trace?.write({
|
|
198
|
+
stream: "rpc_in",
|
|
199
|
+
direction: "in",
|
|
200
|
+
id: msg.id,
|
|
201
|
+
method: msg.method,
|
|
202
|
+
status: "request",
|
|
203
|
+
params: msg.params,
|
|
204
|
+
});
|
|
170
205
|
void this.handleServerRequest(msg.id, msg.method, msg.params);
|
|
171
206
|
} else {
|
|
207
|
+
this.trace?.write({
|
|
208
|
+
stream: "rpc_in",
|
|
209
|
+
direction: "in",
|
|
210
|
+
method: msg.method,
|
|
211
|
+
status: "notification",
|
|
212
|
+
params: msg.params,
|
|
213
|
+
});
|
|
172
214
|
try {
|
|
173
215
|
this.handlers.onNotification(msg.method, msg.params);
|
|
174
216
|
} catch (err) {
|
|
@@ -199,6 +241,15 @@ class AcpConnection {
|
|
|
199
241
|
const reply = error
|
|
200
242
|
? { jsonrpc: "2.0", id, error }
|
|
201
243
|
: { jsonrpc: "2.0", id, result: result ?? null };
|
|
244
|
+
this.trace?.write({
|
|
245
|
+
stream: "rpc_out",
|
|
246
|
+
direction: "out",
|
|
247
|
+
id,
|
|
248
|
+
status: error ? "error" : "response",
|
|
249
|
+
code: error?.code,
|
|
250
|
+
error: error?.message,
|
|
251
|
+
result: error ? undefined : result ?? null,
|
|
252
|
+
});
|
|
202
253
|
this.writeMessage(reply);
|
|
203
254
|
}
|
|
204
255
|
|
|
@@ -221,11 +272,26 @@ class AcpConnection {
|
|
|
221
272
|
resolve: (v) => resolve(v as T),
|
|
222
273
|
reject,
|
|
223
274
|
});
|
|
275
|
+
this.trace?.write({
|
|
276
|
+
stream: "rpc_out",
|
|
277
|
+
direction: "out",
|
|
278
|
+
id,
|
|
279
|
+
method,
|
|
280
|
+
status: "request",
|
|
281
|
+
params,
|
|
282
|
+
});
|
|
224
283
|
this.writeMessage({ jsonrpc: "2.0", id, method, params });
|
|
225
284
|
});
|
|
226
285
|
}
|
|
227
286
|
|
|
228
287
|
notify(method: string, params: unknown): void {
|
|
288
|
+
this.trace?.write({
|
|
289
|
+
stream: "rpc_out",
|
|
290
|
+
direction: "out",
|
|
291
|
+
method,
|
|
292
|
+
status: "notification",
|
|
293
|
+
params,
|
|
294
|
+
});
|
|
229
295
|
this.writeMessage({ jsonrpc: "2.0", method, params });
|
|
230
296
|
}
|
|
231
297
|
|
|
@@ -323,6 +389,20 @@ export abstract class AcpRuntimeAdapter implements RuntimeAdapter {
|
|
|
323
389
|
env: this.spawnEnv(opts),
|
|
324
390
|
stdio: ["pipe", "pipe", "pipe"],
|
|
325
391
|
}) as ChildProcessWithoutNullStreams;
|
|
392
|
+
const trace = createAcpTraceLogger({
|
|
393
|
+
runtime: this.id,
|
|
394
|
+
accountId: opts.accountId,
|
|
395
|
+
turnId: stringField(opts.context, "turnId"),
|
|
396
|
+
roomId: stringField(opts.context, "roomId"),
|
|
397
|
+
topicId: stringField(opts.context, "topicId") ?? null,
|
|
398
|
+
hermesProfile: opts.hermesProfile,
|
|
399
|
+
sessionId: opts.sessionId,
|
|
400
|
+
});
|
|
401
|
+
trace?.write({
|
|
402
|
+
stream: "child_start",
|
|
403
|
+
pid: child.pid,
|
|
404
|
+
params: { command: binary, args, cwd: opts.cwd },
|
|
405
|
+
});
|
|
326
406
|
|
|
327
407
|
let killTimer: ReturnType<typeof setTimeout> | null = null;
|
|
328
408
|
const onAbort = () => {
|
|
@@ -351,6 +431,7 @@ export abstract class AcpRuntimeAdapter implements RuntimeAdapter {
|
|
|
351
431
|
child.stderr.setEncoding("utf8");
|
|
352
432
|
child.stderr.on("data", (chunk: string) => {
|
|
353
433
|
stderrTail = (stderrTail + chunk).slice(-STDERR_TAIL_CAP);
|
|
434
|
+
trace?.write({ stream: "stderr", pid: child.pid, chunk });
|
|
354
435
|
});
|
|
355
436
|
|
|
356
437
|
const state: AcpRunState = {
|
|
@@ -414,13 +495,25 @@ export abstract class AcpRuntimeAdapter implements RuntimeAdapter {
|
|
|
414
495
|
},
|
|
415
496
|
},
|
|
416
497
|
this.id,
|
|
498
|
+
trace,
|
|
417
499
|
);
|
|
418
500
|
|
|
419
501
|
const childExit = new Promise<number>((resolve) => {
|
|
420
|
-
child.on("close", (code) =>
|
|
502
|
+
child.on("close", (code, signal) => {
|
|
503
|
+
trace?.write({ stream: "child_exit", pid: child.pid, code, signal });
|
|
504
|
+
resolve(code ?? 0);
|
|
505
|
+
});
|
|
506
|
+
child.on("error", (err) => {
|
|
507
|
+
trace?.write({
|
|
508
|
+
stream: "child_error",
|
|
509
|
+
pid: child.pid,
|
|
510
|
+
error: err instanceof Error ? err.message : String(err),
|
|
511
|
+
});
|
|
512
|
+
});
|
|
421
513
|
});
|
|
422
514
|
|
|
423
515
|
let newSessionId = opts.sessionId ?? "";
|
|
516
|
+
let promptStarted = false;
|
|
424
517
|
|
|
425
518
|
try {
|
|
426
519
|
// 1) initialize
|
|
@@ -471,6 +564,7 @@ export abstract class AcpRuntimeAdapter implements RuntimeAdapter {
|
|
|
471
564
|
newSessionId = sessionId;
|
|
472
565
|
|
|
473
566
|
// 3) session/prompt
|
|
567
|
+
promptStarted = true;
|
|
474
568
|
const promptResult = (await conn.request<unknown>("session/prompt", {
|
|
475
569
|
sessionId,
|
|
476
570
|
prompt: [{ type: "text", text: opts.text }],
|
|
@@ -508,6 +602,9 @@ export abstract class AcpRuntimeAdapter implements RuntimeAdapter {
|
|
|
508
602
|
const tail = stderrTail.slice(-STDERR_ERROR_SNIPPET).trim();
|
|
509
603
|
state.errorText =
|
|
510
604
|
state.errorText ?? (tail ? `${baseMsg}; stderr: ${tail}` : baseMsg);
|
|
605
|
+
if (promptStarted && !opts.signal.aborted) {
|
|
606
|
+
await sleepUnlessAborted(PROMPT_ERROR_DRAIN_MS, opts.signal);
|
|
607
|
+
}
|
|
511
608
|
try {
|
|
512
609
|
child.stdin.end();
|
|
513
610
|
} catch {
|
|
@@ -563,3 +660,17 @@ export abstract class AcpRuntimeAdapter implements RuntimeAdapter {
|
|
|
563
660
|
});
|
|
564
661
|
}
|
|
565
662
|
}
|
|
663
|
+
|
|
664
|
+
function sleepUnlessAborted(ms: number, signal: AbortSignal): Promise<void> {
|
|
665
|
+
if (signal.aborted) return Promise.resolve();
|
|
666
|
+
return new Promise((resolve) => {
|
|
667
|
+
const t = setTimeout(done, ms);
|
|
668
|
+
if (typeof t.unref === "function") t.unref();
|
|
669
|
+
function done(): void {
|
|
670
|
+
signal.removeEventListener("abort", done);
|
|
671
|
+
clearTimeout(t);
|
|
672
|
+
resolve();
|
|
673
|
+
}
|
|
674
|
+
signal.addEventListener("abort", done, { once: true });
|
|
675
|
+
});
|
|
676
|
+
}
|