@botcord/daemon 0.2.61 → 0.2.62
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.js +58 -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__/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 +60 -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
|
@@ -202,6 +202,45 @@ describe("OpenclawAcpAdapter.run", () => {
|
|
|
202
202
|
expect(res.error).toContain("Missing env var FOO_API_KEY");
|
|
203
203
|
});
|
|
204
204
|
|
|
205
|
+
it("treats end_turn with warning-only stdout as an empty reply, not an error", async () => {
|
|
206
|
+
const child = new FakeChild();
|
|
207
|
+
const adapter = new OpenclawAcpAdapter({ spawnFn: makeSpawn(child) });
|
|
208
|
+
const gateway: ResolvedOpenclawGateway = {
|
|
209
|
+
name: "local",
|
|
210
|
+
url: "ws://127.0.0.1:1",
|
|
211
|
+
openclawAgent: "main",
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
child.stdin.on("data", (chunk: Buffer) => {
|
|
215
|
+
for (const line of chunk.toString("utf8").split("\n").filter(Boolean)) {
|
|
216
|
+
const frame = JSON.parse(line);
|
|
217
|
+
if (frame.method === "initialize") {
|
|
218
|
+
child.stdout.write("◇ Config warnings ─────────────────────╮\n");
|
|
219
|
+
child.stdout.write("│ - plugins.allow: plugin not installed: brave │\n");
|
|
220
|
+
child.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: frame.id, result: { protocolVersion: 1 } }) + "\n");
|
|
221
|
+
} else if (frame.method === "session/new") {
|
|
222
|
+
child.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: frame.id, result: { sessionId: "sid-warn-end" } }) + "\n");
|
|
223
|
+
} else if (frame.method === "session/prompt") {
|
|
224
|
+
child.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: frame.id, result: { stopReason: "end_turn" } }) + "\n");
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
const res = await adapter.run({
|
|
230
|
+
text: "hi",
|
|
231
|
+
sessionId: null,
|
|
232
|
+
cwd: "/tmp",
|
|
233
|
+
accountId: "ag_alice",
|
|
234
|
+
signal: new AbortController().signal,
|
|
235
|
+
trustLevel: "owner",
|
|
236
|
+
gateway,
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
expect(res.text).toBe("");
|
|
240
|
+
expect(res.newSessionId).toBe("sid-warn-end");
|
|
241
|
+
expect(res.error).toBeUndefined();
|
|
242
|
+
});
|
|
243
|
+
|
|
205
244
|
it("streams only final text when OpenClaw sends reasoning before a final block", async () => {
|
|
206
245
|
const child = new FakeChild();
|
|
207
246
|
const adapter = new OpenclawAcpAdapter({ spawnFn: makeSpawn(child) });
|
package/src/acp-logs.ts
ADDED
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, mkdirSync, readdirSync, renameSync, statSync, unlinkSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import type { LogFileEntry } from "./log.js";
|
|
5
|
+
|
|
6
|
+
const ACP_LOG_DIR = path.join(homedir(), ".botcord", "logs", "acp");
|
|
7
|
+
const ACP_LOG_MAX_BYTES = 2 * 1024 * 1024;
|
|
8
|
+
const ACP_LOG_KEEP = 20;
|
|
9
|
+
const ACP_LOG_DIAGNOSTICS_DEFAULT = 10;
|
|
10
|
+
const ACP_LOG_DIAGNOSTICS_ALL = 50;
|
|
11
|
+
const RUNTIME_LOG_DEFAULT_PER_ROOT = 5;
|
|
12
|
+
const RUNTIME_LOG_ALL_PER_ROOT = 25;
|
|
13
|
+
const RUNTIME_LOG_MAX_FILE_BYTES = 2 * 1024 * 1024;
|
|
14
|
+
|
|
15
|
+
const SECRET_KEY_RE = /token|secret|private.?key|api.?key|authorization|password/i;
|
|
16
|
+
|
|
17
|
+
export type AcpTraceStream =
|
|
18
|
+
| "child_start"
|
|
19
|
+
| "child_exit"
|
|
20
|
+
| "child_error"
|
|
21
|
+
| "stderr"
|
|
22
|
+
| "stdout_non_json"
|
|
23
|
+
| "rpc_in"
|
|
24
|
+
| "rpc_out";
|
|
25
|
+
|
|
26
|
+
export interface AcpTraceMeta {
|
|
27
|
+
runtime: string;
|
|
28
|
+
accountId?: string;
|
|
29
|
+
turnId?: string;
|
|
30
|
+
roomId?: string;
|
|
31
|
+
topicId?: string | null;
|
|
32
|
+
gatewayName?: string;
|
|
33
|
+
gatewayUrl?: string;
|
|
34
|
+
hermesProfile?: string;
|
|
35
|
+
sessionId?: string | null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface AcpTraceEvent {
|
|
39
|
+
stream: AcpTraceStream;
|
|
40
|
+
direction?: "in" | "out";
|
|
41
|
+
pid?: number;
|
|
42
|
+
id?: number | string;
|
|
43
|
+
method?: string;
|
|
44
|
+
status?: "request" | "notification" | "response" | "error";
|
|
45
|
+
code?: number | null;
|
|
46
|
+
signal?: NodeJS.Signals | null;
|
|
47
|
+
error?: string;
|
|
48
|
+
chunk?: string;
|
|
49
|
+
params?: unknown;
|
|
50
|
+
result?: unknown;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface AcpTraceLogger {
|
|
54
|
+
path: string;
|
|
55
|
+
verbose: boolean;
|
|
56
|
+
write(event: AcpTraceEvent): void;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface RuntimeLogRoot {
|
|
60
|
+
label: string;
|
|
61
|
+
dir: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface RuntimeLogFile extends LogFileEntry {
|
|
65
|
+
bundleName: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function createAcpTraceLogger(meta: AcpTraceMeta): AcpTraceLogger | null {
|
|
69
|
+
if (process.env.BOTCORD_ACP_LOGS === "0") return null;
|
|
70
|
+
const runtime = safePathSegment(meta.runtime || "acp");
|
|
71
|
+
const key = safePathSegment(
|
|
72
|
+
[
|
|
73
|
+
meta.accountId,
|
|
74
|
+
meta.gatewayName,
|
|
75
|
+
meta.hermesProfile,
|
|
76
|
+
meta.roomId,
|
|
77
|
+
].filter(Boolean).join("_") || "default",
|
|
78
|
+
);
|
|
79
|
+
const dir = path.join(ACP_LOG_DIR, runtime);
|
|
80
|
+
const file = path.join(dir, `${key}.jsonl`);
|
|
81
|
+
const verbose = process.env.BOTCORD_ACP_TRACE === "verbose";
|
|
82
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
83
|
+
return {
|
|
84
|
+
path: file,
|
|
85
|
+
verbose,
|
|
86
|
+
write(event) {
|
|
87
|
+
writeAcpTrace(file, meta, event, verbose);
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function listAcpTraceLogFiles(includeAll = false): LogFileEntry[] {
|
|
93
|
+
const limit = includeAll ? ACP_LOG_DIAGNOSTICS_ALL : ACP_LOG_DIAGNOSTICS_DEFAULT;
|
|
94
|
+
const out: LogFileEntry[] = [];
|
|
95
|
+
collectFiles(ACP_LOG_DIR, out, (name) => name.endsWith(".jsonl") || name.includes(".jsonl."));
|
|
96
|
+
return out
|
|
97
|
+
.sort((a, b) => b.mtimeMs - a.mtimeMs || b.name.localeCompare(a.name))
|
|
98
|
+
.slice(0, limit);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function listRuntimeLogFiles(includeAll = false): RuntimeLogFile[] {
|
|
102
|
+
const limit = includeAll ? RUNTIME_LOG_ALL_PER_ROOT : RUNTIME_LOG_DEFAULT_PER_ROOT;
|
|
103
|
+
const out: RuntimeLogFile[] = [];
|
|
104
|
+
for (const root of runtimeLogRoots()) {
|
|
105
|
+
const files: LogFileEntry[] = [];
|
|
106
|
+
collectFiles(root.dir, files, looksLikeLogFile, 4);
|
|
107
|
+
for (const entry of files
|
|
108
|
+
.sort((a, b) => b.mtimeMs - a.mtimeMs || b.name.localeCompare(a.name))
|
|
109
|
+
.slice(0, limit)) {
|
|
110
|
+
out.push({
|
|
111
|
+
...entry,
|
|
112
|
+
bundleName: path.posix.join(
|
|
113
|
+
"runtime-logs",
|
|
114
|
+
root.label,
|
|
115
|
+
relativeBundlePath(root.dir, entry.path),
|
|
116
|
+
),
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function writeAcpTrace(
|
|
124
|
+
file: string,
|
|
125
|
+
meta: AcpTraceMeta,
|
|
126
|
+
event: AcpTraceEvent,
|
|
127
|
+
verbose: boolean,
|
|
128
|
+
): void {
|
|
129
|
+
try {
|
|
130
|
+
rotateIfNeeded(file);
|
|
131
|
+
const record = {
|
|
132
|
+
ts: new Date().toISOString(),
|
|
133
|
+
runtime: meta.runtime,
|
|
134
|
+
accountId: meta.accountId,
|
|
135
|
+
turnId: meta.turnId,
|
|
136
|
+
roomId: meta.roomId,
|
|
137
|
+
topicId: meta.topicId ?? undefined,
|
|
138
|
+
gatewayName: meta.gatewayName,
|
|
139
|
+
gatewayUrl: meta.gatewayUrl,
|
|
140
|
+
hermesProfile: meta.hermesProfile,
|
|
141
|
+
sessionId: event.params && typeof event.params === "object"
|
|
142
|
+
? pickString(event.params as Record<string, unknown>, "sessionId") ?? meta.sessionId ?? undefined
|
|
143
|
+
: meta.sessionId ?? undefined,
|
|
144
|
+
...summarizeEvent(event, verbose),
|
|
145
|
+
};
|
|
146
|
+
appendFileSync(file, JSON.stringify(record) + "\n", { mode: 0o600 });
|
|
147
|
+
} catch {
|
|
148
|
+
// ACP trace logging must never affect runtime execution.
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function summarizeEvent(event: AcpTraceEvent, verbose: boolean): Record<string, unknown> {
|
|
153
|
+
const out: Record<string, unknown> = {
|
|
154
|
+
stream: event.stream,
|
|
155
|
+
};
|
|
156
|
+
if (event.direction) out.direction = event.direction;
|
|
157
|
+
if (event.pid !== undefined) out.pid = event.pid;
|
|
158
|
+
if (event.id !== undefined) out.id = event.id;
|
|
159
|
+
if (event.method) out.method = event.method;
|
|
160
|
+
if (event.status) out.status = event.status;
|
|
161
|
+
if (event.code !== undefined) out.code = event.code;
|
|
162
|
+
if (event.signal !== undefined) out.signal = event.signal;
|
|
163
|
+
if (event.error) out.error = truncate(event.error, 1000);
|
|
164
|
+
if (event.chunk) out.chunk = truncate(redactSecretString(event.chunk), 2000);
|
|
165
|
+
if (event.params !== undefined) out.params = summarizePayload(event.params, verbose);
|
|
166
|
+
if (event.result !== undefined) out.result = summarizePayload(event.result, verbose);
|
|
167
|
+
return out;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function summarizePayload(value: unknown, verbose: boolean): unknown {
|
|
171
|
+
const redacted = redactSecrets(value);
|
|
172
|
+
if (verbose) return capPayload(redacted);
|
|
173
|
+
if (Array.isArray(redacted)) return { type: "array", length: redacted.length };
|
|
174
|
+
if (!redacted || typeof redacted !== "object") return redacted;
|
|
175
|
+
const obj = redacted as Record<string, unknown>;
|
|
176
|
+
const out: Record<string, unknown> = {};
|
|
177
|
+
for (const [key, v] of Object.entries(obj)) {
|
|
178
|
+
if (key === "prompt") {
|
|
179
|
+
out.prompt = summarizePrompt(v);
|
|
180
|
+
} else if (key === "cwd" || key === "sessionId") {
|
|
181
|
+
out[key] = v;
|
|
182
|
+
} else if (key === "_meta") {
|
|
183
|
+
out[key] = summarizePayload(v, false);
|
|
184
|
+
} else if (key === "update") {
|
|
185
|
+
out.update = summarizeUpdate(v);
|
|
186
|
+
} else if (key === "toolCall") {
|
|
187
|
+
out.toolCall = summarizeToolCall(v);
|
|
188
|
+
} else if (typeof v === "string") {
|
|
189
|
+
out[key] = stringSummary(v);
|
|
190
|
+
} else if (Array.isArray(v)) {
|
|
191
|
+
out[key] = { type: "array", length: v.length };
|
|
192
|
+
} else if (v && typeof v === "object") {
|
|
193
|
+
out[key] = { type: "object", keys: Object.keys(v as Record<string, unknown>).slice(0, 20) };
|
|
194
|
+
} else {
|
|
195
|
+
out[key] = v;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return out;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function summarizePrompt(value: unknown): unknown {
|
|
202
|
+
if (!Array.isArray(value)) return summarizePayload(value, false);
|
|
203
|
+
return value.map((item) => {
|
|
204
|
+
if (!item || typeof item !== "object") return summarizePayload(item, false);
|
|
205
|
+
const obj = item as Record<string, unknown>;
|
|
206
|
+
const text = typeof obj.text === "string" ? obj.text : "";
|
|
207
|
+
return {
|
|
208
|
+
type: obj.type,
|
|
209
|
+
textBytes: text ? Buffer.byteLength(text, "utf8") : undefined,
|
|
210
|
+
textPreview: text ? truncate(text.replace(/\s+/g, " "), 120) : undefined,
|
|
211
|
+
};
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function summarizeUpdate(value: unknown): unknown {
|
|
216
|
+
if (!value || typeof value !== "object") return summarizePayload(value, false);
|
|
217
|
+
const obj = value as Record<string, unknown>;
|
|
218
|
+
const out: Record<string, unknown> = {};
|
|
219
|
+
if (typeof obj.sessionUpdate === "string") out.sessionUpdate = obj.sessionUpdate;
|
|
220
|
+
if (typeof obj.title === "string") out.title = stringSummary(obj.title);
|
|
221
|
+
if (obj.content !== undefined) out.content = summarizePayload(obj.content, false);
|
|
222
|
+
return Object.keys(out).length > 0 ? out : { type: "object", keys: Object.keys(obj).slice(0, 20) };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function summarizeToolCall(value: unknown): unknown {
|
|
226
|
+
if (!value || typeof value !== "object") return summarizePayload(value, false);
|
|
227
|
+
const obj = value as Record<string, unknown>;
|
|
228
|
+
return {
|
|
229
|
+
name: typeof obj.name === "string" ? obj.name : undefined,
|
|
230
|
+
rawInput: obj.rawInput === undefined ? undefined : summarizePayload(obj.rawInput, false),
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function redactSecrets(value: unknown): unknown {
|
|
235
|
+
if (Array.isArray(value)) return value.map(redactSecrets);
|
|
236
|
+
if (!value || typeof value !== "object") {
|
|
237
|
+
return typeof value === "string" ? redactSecretString(value) : value;
|
|
238
|
+
}
|
|
239
|
+
const out: Record<string, unknown> = {};
|
|
240
|
+
for (const [key, v] of Object.entries(value as Record<string, unknown>)) {
|
|
241
|
+
out[key] = SECRET_KEY_RE.test(key) ? "[REDACTED]" : redactSecrets(v);
|
|
242
|
+
}
|
|
243
|
+
return out;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function redactSecretString(value: string): string {
|
|
247
|
+
return value
|
|
248
|
+
.replace(/(Authorization:\s*Bearer\s+)[^\s"']+/gi, "$1[REDACTED]")
|
|
249
|
+
.replace(/\b(token=)[^\s"']+/gi, "$1[REDACTED]")
|
|
250
|
+
.replace(/\b(drt_|dit_|gho_)[A-Za-z0-9_-]+/g, "$1[REDACTED]");
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function capPayload(value: unknown): unknown {
|
|
254
|
+
if (typeof value === "string") return truncate(value, 2000);
|
|
255
|
+
if (Array.isArray(value)) return value.slice(0, 50).map(capPayload);
|
|
256
|
+
if (!value || typeof value !== "object") return value;
|
|
257
|
+
const out: Record<string, unknown> = {};
|
|
258
|
+
for (const [key, v] of Object.entries(value as Record<string, unknown>).slice(0, 50)) {
|
|
259
|
+
out[key] = capPayload(v);
|
|
260
|
+
}
|
|
261
|
+
return out;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function stringSummary(value: string): Record<string, unknown> {
|
|
265
|
+
return {
|
|
266
|
+
bytes: Buffer.byteLength(value, "utf8"),
|
|
267
|
+
preview: truncate(value.replace(/\s+/g, " "), 160),
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function truncate(value: string, max: number): string {
|
|
272
|
+
return value.length > max ? `${value.slice(0, max)}…` : value;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function pickString(obj: Record<string, unknown>, key: string): string | undefined {
|
|
276
|
+
const value = obj[key];
|
|
277
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function rotateIfNeeded(file: string): void {
|
|
281
|
+
try {
|
|
282
|
+
const st = statSync(file);
|
|
283
|
+
if (!st.isFile() || st.size <= ACP_LOG_MAX_BYTES) return;
|
|
284
|
+
renameSync(file, `${file}.${new Date().toISOString().replace(/[:.]/g, "-")}.${process.pid}`);
|
|
285
|
+
const dir = path.dirname(file);
|
|
286
|
+
const base = path.basename(file);
|
|
287
|
+
const rotated = readdirSync(dir)
|
|
288
|
+
.filter((name) => name.startsWith(`${base}.`))
|
|
289
|
+
.map((name) => {
|
|
290
|
+
const p = path.join(dir, name);
|
|
291
|
+
const st = statSync(p);
|
|
292
|
+
return { p, mtimeMs: st.mtimeMs };
|
|
293
|
+
})
|
|
294
|
+
.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
295
|
+
for (const entry of rotated.slice(ACP_LOG_KEEP)) unlinkSync(entry.p);
|
|
296
|
+
} catch {
|
|
297
|
+
// best-effort
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function collectFiles(
|
|
302
|
+
dir: string,
|
|
303
|
+
out: LogFileEntry[],
|
|
304
|
+
accept: (name: string, file: string) => boolean,
|
|
305
|
+
maxDepth = 3,
|
|
306
|
+
): void {
|
|
307
|
+
if (maxDepth < 0) return;
|
|
308
|
+
let names: string[];
|
|
309
|
+
try {
|
|
310
|
+
names = readdirSync(dir);
|
|
311
|
+
} catch {
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
for (const name of names) {
|
|
315
|
+
const file = path.join(dir, name);
|
|
316
|
+
try {
|
|
317
|
+
const st = statSync(file);
|
|
318
|
+
if (st.isDirectory()) {
|
|
319
|
+
collectFiles(file, out, accept, maxDepth - 1);
|
|
320
|
+
} else if (st.isFile() && st.size <= RUNTIME_LOG_MAX_FILE_BYTES && accept(name, file)) {
|
|
321
|
+
out.push({
|
|
322
|
+
path: file,
|
|
323
|
+
name: path.relative(ACP_LOG_DIR, file) || name,
|
|
324
|
+
sizeBytes: st.size,
|
|
325
|
+
mtimeMs: st.mtimeMs,
|
|
326
|
+
active: true,
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
} catch {
|
|
330
|
+
// ignore disappearing files
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function runtimeLogRoots(): RuntimeLogRoot[] {
|
|
336
|
+
const roots: RuntimeLogRoot[] = [
|
|
337
|
+
{ label: "openclaw", dir: path.join(homedir(), ".openclaw", "logs") },
|
|
338
|
+
{ label: "qclaw", dir: path.join(homedir(), ".qclaw", "logs") },
|
|
339
|
+
{ label: "hermes", dir: path.join(homedir(), ".hermes", "logs") },
|
|
340
|
+
];
|
|
341
|
+
const hermesProfiles = path.join(homedir(), ".hermes", "profiles");
|
|
342
|
+
try {
|
|
343
|
+
for (const name of readdirSync(hermesProfiles)) {
|
|
344
|
+
roots.push({
|
|
345
|
+
label: path.posix.join("hermes-profiles", safePathSegment(name)),
|
|
346
|
+
dir: path.join(hermesProfiles, name, "logs"),
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
} catch {
|
|
350
|
+
// no profiles
|
|
351
|
+
}
|
|
352
|
+
const botcordAgents = path.join(homedir(), ".botcord", "agents");
|
|
353
|
+
try {
|
|
354
|
+
for (const agent of readdirSync(botcordAgents)) {
|
|
355
|
+
roots.push({
|
|
356
|
+
label: path.posix.join("botcord-hermes", safePathSegment(agent)),
|
|
357
|
+
dir: path.join(botcordAgents, agent, "hermes-home", "logs"),
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
} catch {
|
|
361
|
+
// no botcord agent homes
|
|
362
|
+
}
|
|
363
|
+
return roots.filter((root) => existsSync(root.dir));
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function looksLikeLogFile(name: string): boolean {
|
|
367
|
+
const lower = name.toLowerCase();
|
|
368
|
+
return (
|
|
369
|
+
lower.endsWith(".log") ||
|
|
370
|
+
lower.endsWith(".jsonl") ||
|
|
371
|
+
lower.endsWith(".txt") ||
|
|
372
|
+
lower.includes("log")
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function relativeBundlePath(root: string, file: string): string {
|
|
377
|
+
return path.relative(root, file).split(path.sep).map(safePathSegment).join("/");
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function safePathSegment(raw: string): string {
|
|
381
|
+
return raw.replace(/[^A-Za-z0-9._-]+/g, "_").slice(0, 120) || "unknown";
|
|
382
|
+
}
|
package/src/diagnostics.ts
CHANGED
|
@@ -14,9 +14,11 @@ import {
|
|
|
14
14
|
PID_PATH,
|
|
15
15
|
SNAPSHOT_PATH,
|
|
16
16
|
loadConfig,
|
|
17
|
+
saveConfig,
|
|
17
18
|
type DaemonConfig,
|
|
18
19
|
} from "./config.js";
|
|
19
20
|
import { listDaemonLogFiles, LOG_FILE_PATH, type LogFileEntry } from "./log.js";
|
|
21
|
+
import { listAcpTraceLogFiles, listRuntimeLogFiles } from "./acp-logs.js";
|
|
20
22
|
import {
|
|
21
23
|
channelsFromDaemonConfig,
|
|
22
24
|
defaultHttpFetcher,
|
|
@@ -26,6 +28,12 @@ import {
|
|
|
26
28
|
type DoctorRuntimeEntry,
|
|
27
29
|
} from "./doctor.js";
|
|
28
30
|
import { detectRuntimes } from "./adapters/runtimes.js";
|
|
31
|
+
import { log as daemonLog } from "./log.js";
|
|
32
|
+
import {
|
|
33
|
+
discoverLocalOpenclawGateways,
|
|
34
|
+
mergeOpenclawGateways,
|
|
35
|
+
openclawDiscoveryConfigEnabled,
|
|
36
|
+
} from "./openclaw-discovery.js";
|
|
29
37
|
|
|
30
38
|
const DIAGNOSTICS_DIR = path.join(homedir(), ".botcord", "diagnostics");
|
|
31
39
|
const MAX_UPLOAD_BYTES = 50 * 1024 * 1024;
|
|
@@ -124,6 +132,7 @@ async function buildDoctorEntries(): Promise<{
|
|
|
124
132
|
let cfgForEndpoints: DaemonConfig | null = null;
|
|
125
133
|
try {
|
|
126
134
|
cfgForEndpoints = loadConfig();
|
|
135
|
+
cfgForEndpoints = await refreshDiscoveredOpenclawGateways(cfgForEndpoints);
|
|
127
136
|
channels = channelsFromDaemonConfig(cfgForEndpoints);
|
|
128
137
|
} catch {
|
|
129
138
|
channels = [];
|
|
@@ -147,6 +156,31 @@ async function buildDoctorEntries(): Promise<{
|
|
|
147
156
|
return { text: renderDoctor(input), json: input };
|
|
148
157
|
}
|
|
149
158
|
|
|
159
|
+
async function refreshDiscoveredOpenclawGateways(cfg: DaemonConfig): Promise<DaemonConfig> {
|
|
160
|
+
if (!openclawDiscoveryConfigEnabled(cfg)) return cfg;
|
|
161
|
+
try {
|
|
162
|
+
const found = await discoverLocalOpenclawGateways({
|
|
163
|
+
searchPaths: cfg.openclawDiscovery?.searchPaths,
|
|
164
|
+
defaultPorts: cfg.openclawDiscovery?.defaultPorts,
|
|
165
|
+
timeoutMs: 500,
|
|
166
|
+
});
|
|
167
|
+
const merged = mergeOpenclawGateways(cfg, found);
|
|
168
|
+
if (!merged.changed) return cfg;
|
|
169
|
+
saveConfig(merged.cfg);
|
|
170
|
+
daemonLog.info("openclaw discovery: gateways merged", {
|
|
171
|
+
source: "diagnostics",
|
|
172
|
+
added: merged.added.map((g) => ({ name: g.name, url: g.url })),
|
|
173
|
+
});
|
|
174
|
+
return merged.cfg;
|
|
175
|
+
} catch (err) {
|
|
176
|
+
daemonLog.warn("openclaw discovery failed; continuing", {
|
|
177
|
+
source: "diagnostics",
|
|
178
|
+
error: err instanceof Error ? err.message : String(err),
|
|
179
|
+
});
|
|
180
|
+
return cfg;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
150
184
|
function crc32(buf: Buffer): number {
|
|
151
185
|
let crc = 0xffffffff;
|
|
152
186
|
for (const b of buf) {
|
|
@@ -297,6 +331,8 @@ export async function createDiagnosticBundle(
|
|
|
297
331
|
const snapshotFile = opts.snapshotFile ?? SNAPSHOT_PATH;
|
|
298
332
|
const includeAllLogs = opts.includeAllLogs === true;
|
|
299
333
|
const logs = bundledLogs(logFile, includeAllLogs);
|
|
334
|
+
const acpLogs = listAcpTraceLogFiles(includeAllLogs);
|
|
335
|
+
const runtimeLogs = listRuntimeLogFiles(includeAllLogs);
|
|
300
336
|
mkdirSync(diagnosticsDir, { recursive: true, mode: 0o700 });
|
|
301
337
|
|
|
302
338
|
const doctor = opts.doctor ?? await buildDoctorEntries();
|
|
@@ -318,6 +354,16 @@ export async function createDiagnosticBundle(
|
|
|
318
354
|
sizeBytes: entry.sizeBytes,
|
|
319
355
|
active: entry.active,
|
|
320
356
|
})),
|
|
357
|
+
acpLogsBundled: acpLogs.map((entry) => ({
|
|
358
|
+
name: entry.name,
|
|
359
|
+
path: entry.path,
|
|
360
|
+
sizeBytes: entry.sizeBytes,
|
|
361
|
+
})),
|
|
362
|
+
runtimeLogsBundled: runtimeLogs.map((entry) => ({
|
|
363
|
+
name: entry.bundleName,
|
|
364
|
+
path: entry.path,
|
|
365
|
+
sizeBytes: entry.sizeBytes,
|
|
366
|
+
})),
|
|
321
367
|
logsBundleMode: includeAllLogs ? "all" : `active_plus_${DEFAULT_ROTATED_LOGS_IN_BUNDLE}_rotated`,
|
|
322
368
|
diagnosticsDir,
|
|
323
369
|
userAuth: readUserAuthSummary(),
|
|
@@ -343,6 +389,20 @@ export async function createDiagnosticBundle(
|
|
|
343
389
|
});
|
|
344
390
|
}
|
|
345
391
|
}
|
|
392
|
+
for (const entry of acpLogs) {
|
|
393
|
+
const log = safeReadText(entry.path);
|
|
394
|
+
entries.push({
|
|
395
|
+
name: `acp-logs/${entry.name.split(path.sep).join("/")}`,
|
|
396
|
+
data: log ?? `no ACP log file at ${entry.path}\n`,
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
for (const entry of runtimeLogs) {
|
|
400
|
+
const log = safeReadText(entry.path);
|
|
401
|
+
entries.push({
|
|
402
|
+
name: entry.bundleName,
|
|
403
|
+
data: log ?? `no runtime log file at ${entry.path}\n`,
|
|
404
|
+
});
|
|
405
|
+
}
|
|
346
406
|
const config = safeReadText(configFile);
|
|
347
407
|
entries.push({
|
|
348
408
|
name: "config.json.redacted",
|
|
@@ -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", {
|