@botcord/daemon 0.2.13 → 0.2.15
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-workspace.js +47 -1
- package/dist/gateway/channels/botcord.js +39 -0
- package/dist/gateway/dispatcher.d.ts +6 -0
- package/dist/gateway/dispatcher.js +207 -9
- package/dist/gateway/runtimes/acp-stream.d.ts +7 -1
- package/dist/gateway/runtimes/acp-stream.js +19 -0
- package/dist/gateway/runtimes/claude-code.js +34 -0
- package/dist/gateway/runtimes/codex.js +50 -0
- package/dist/gateway/runtimes/hermes-agent.d.ts +8 -3
- package/dist/gateway/runtimes/hermes-agent.js +36 -6
- package/dist/gateway/runtimes/ndjson-stream.d.ts +8 -1
- package/dist/gateway/runtimes/ndjson-stream.js +8 -0
- package/dist/gateway/types.d.ts +54 -2
- package/dist/index.js +72 -5
- package/dist/provision.js +63 -1
- package/package.json +1 -1
- package/src/__tests__/agent-workspace.test.ts +25 -0
- package/src/__tests__/provision.test.ts +68 -1
- package/src/agent-workspace.ts +47 -0
- package/src/gateway/__tests__/botcord-channel.test.ts +97 -0
- package/src/gateway/__tests__/claude-code-adapter.test.ts +35 -0
- package/src/gateway/__tests__/codex-adapter.test.ts +44 -0
- package/src/gateway/__tests__/dispatcher.test.ts +552 -1
- package/src/gateway/__tests__/hermes-agent-adapter.test.ts +39 -0
- package/src/gateway/channels/botcord.ts +38 -0
- package/src/gateway/dispatcher.ts +217 -15
- package/src/gateway/runtimes/acp-stream.ts +24 -0
- package/src/gateway/runtimes/claude-code.ts +41 -1
- package/src/gateway/runtimes/codex.ts +58 -0
- package/src/gateway/runtimes/hermes-agent.ts +45 -5
- package/src/gateway/runtimes/ndjson-stream.ts +15 -0
- package/src/gateway/types.ts +55 -2
- package/src/index.ts +88 -5
- package/src/provision.ts +62 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { RuntimeAdapter, RuntimeProbeResult, RuntimeRunOptions, RuntimeRunResult, StreamBlock } from "../types.js";
|
|
1
|
+
import type { RuntimeAdapter, RuntimeProbeResult, RuntimeRunOptions, RuntimeRunResult, RuntimeStatusEvent, StreamBlock } from "../types.js";
|
|
2
2
|
/**
|
|
3
3
|
* Mutable state threaded through event callbacks while a single turn runs.
|
|
4
4
|
* The base class reads these fields to assemble the final RuntimeRunResult.
|
|
@@ -29,6 +29,13 @@ export interface NdjsonEventCtx {
|
|
|
29
29
|
* Subclasses should use this instead of `state.assistantTextChunks.push(...)`.
|
|
30
30
|
*/
|
|
31
31
|
appendAssistantText: (text: string) => void;
|
|
32
|
+
/**
|
|
33
|
+
* Forward a runtime status event (typing / thinking) to the dispatcher.
|
|
34
|
+
* Adapters should call this when an event reveals the runtime's lifecycle
|
|
35
|
+
* stage before any visible block lands — e.g. Codex `thread.started`,
|
|
36
|
+
* Claude Code `system` init. Errors thrown here are swallowed.
|
|
37
|
+
*/
|
|
38
|
+
emitStatus: (event: RuntimeStatusEvent) => void;
|
|
32
39
|
}
|
|
33
40
|
/** Base class for runtime adapters that drive a CLI emitting newline-delimited JSON. */
|
|
34
41
|
export declare abstract class NdjsonStreamAdapter implements RuntimeAdapter {
|
|
@@ -136,6 +136,14 @@ export class NdjsonStreamAdapter {
|
|
|
136
136
|
seq,
|
|
137
137
|
emitBlock: (b) => opts.onBlock?.(b),
|
|
138
138
|
appendAssistantText,
|
|
139
|
+
emitStatus: (e) => {
|
|
140
|
+
try {
|
|
141
|
+
opts.onStatus?.(e);
|
|
142
|
+
}
|
|
143
|
+
catch (err) {
|
|
144
|
+
log.warn(`${this.id} onStatus threw`, { err: String(err) });
|
|
145
|
+
}
|
|
146
|
+
},
|
|
139
147
|
});
|
|
140
148
|
}
|
|
141
149
|
catch (err) {
|
package/dist/gateway/types.d.ts
CHANGED
|
@@ -198,6 +198,18 @@ export interface ChannelStreamBlockContext {
|
|
|
198
198
|
block: unknown;
|
|
199
199
|
log: GatewayLogger;
|
|
200
200
|
}
|
|
201
|
+
/**
|
|
202
|
+
* Context passed to `ChannelAdapter.typing()` when the dispatcher signals
|
|
203
|
+
* "agent has accepted this turn but no execution block has surfaced yet".
|
|
204
|
+
* Adapters that bridge to a presence-style API (BotCord `/hub/typing`, etc.)
|
|
205
|
+
* map this into a one-shot ephemeral notification.
|
|
206
|
+
*/
|
|
207
|
+
export interface ChannelTypingContext {
|
|
208
|
+
traceId: string;
|
|
209
|
+
accountId: string;
|
|
210
|
+
conversationId: string;
|
|
211
|
+
log: GatewayLogger;
|
|
212
|
+
}
|
|
201
213
|
/** Upstream messaging surface such as BotCord, Telegram, or WeChat. */
|
|
202
214
|
export interface ChannelAdapter {
|
|
203
215
|
readonly id: string;
|
|
@@ -207,16 +219,48 @@ export interface ChannelAdapter {
|
|
|
207
219
|
send(ctx: ChannelSendContext): Promise<ChannelSendResult>;
|
|
208
220
|
status?(): ChannelStatusSnapshot;
|
|
209
221
|
streamBlock?(ctx: ChannelStreamBlockContext): Promise<void>;
|
|
222
|
+
/**
|
|
223
|
+
* Optional ephemeral "agent is responding" hint. Fire-and-forget; failures
|
|
224
|
+
* must not break the turn. Channels without a presence concept should leave
|
|
225
|
+
* this undefined.
|
|
226
|
+
*/
|
|
227
|
+
typing?(ctx: ChannelTypingContext): Promise<void>;
|
|
210
228
|
}
|
|
211
229
|
/** One parsed block from a runtime's streaming output, forwarded via `onBlock`. */
|
|
212
230
|
export interface StreamBlock {
|
|
213
231
|
/** Raw JSON object as emitted by the underlying CLI (e.g. claude-code stream-json). */
|
|
214
232
|
raw: unknown;
|
|
215
|
-
/**
|
|
216
|
-
|
|
233
|
+
/**
|
|
234
|
+
* Normalized kind, used by channels to decide whether to forward progressive
|
|
235
|
+
* output. `thinking` is synthesized by the dispatcher (or emitted explicitly
|
|
236
|
+
* by an adapter) to represent "the runtime is busy but has nothing visible
|
|
237
|
+
* to show yet" — see `RuntimeStatusEvent`.
|
|
238
|
+
*/
|
|
239
|
+
kind: "assistant_text" | "tool_use" | "tool_result" | "system" | "thinking" | "other";
|
|
217
240
|
/** 1-based sequence number within this turn. */
|
|
218
241
|
seq: number;
|
|
219
242
|
}
|
|
243
|
+
/**
|
|
244
|
+
* Lightweight lifecycle event emitted by runtime adapters and consumed by the
|
|
245
|
+
* dispatcher to drive Dashboard-side `typing` / `thinking` UI states. Not
|
|
246
|
+
* exposed to channels directly — the dispatcher decides how to forward.
|
|
247
|
+
*
|
|
248
|
+
* - `typing` — ephemeral presence; dispatcher pings the channel's
|
|
249
|
+
* `typing()` API on `started`. `stopped` is observed for
|
|
250
|
+
* internal bookkeeping but not forwarded (frontend clears on
|
|
251
|
+
* stream/message arrival).
|
|
252
|
+
* - `thinking` — trace-bound execution state; dispatcher converts each
|
|
253
|
+
* event into a `kind: "thinking"` stream block.
|
|
254
|
+
*/
|
|
255
|
+
export type RuntimeStatusEvent = {
|
|
256
|
+
kind: "typing";
|
|
257
|
+
phase: "started" | "stopped";
|
|
258
|
+
} | {
|
|
259
|
+
kind: "thinking";
|
|
260
|
+
phase: "started" | "updated" | "stopped";
|
|
261
|
+
label?: string;
|
|
262
|
+
raw?: unknown;
|
|
263
|
+
};
|
|
220
264
|
/** Options passed to a runtime adapter for a single turn. */
|
|
221
265
|
export interface RuntimeRunOptions {
|
|
222
266
|
text: string;
|
|
@@ -247,6 +291,14 @@ export interface RuntimeRunOptions {
|
|
|
247
291
|
context?: Record<string, unknown>;
|
|
248
292
|
/** Called for every parsed block while the turn is in progress. */
|
|
249
293
|
onBlock?: (block: StreamBlock) => void;
|
|
294
|
+
/**
|
|
295
|
+
* Optional lifecycle hook for `typing` / `thinking` status. Adapters that
|
|
296
|
+
* can identify session/turn/tool transitions before any `StreamBlock` is
|
|
297
|
+
* available should emit through here so the dispatcher can drive
|
|
298
|
+
* Dashboard-side state. Errors from this callback must be swallowed
|
|
299
|
+
* by the adapter — the dispatcher's handler is fire-and-forget.
|
|
300
|
+
*/
|
|
301
|
+
onStatus?: (event: RuntimeStatusEvent) => void;
|
|
250
302
|
/**
|
|
251
303
|
* External service endpoint required by some runtimes (first user:
|
|
252
304
|
* openclaw-acp). Resolved at config-load time and passed through here per
|
package/dist/index.js
CHANGED
|
@@ -33,7 +33,7 @@ Usage: botcord-daemon <command> [options]
|
|
|
33
33
|
|
|
34
34
|
Commands:
|
|
35
35
|
start [--background|-d] [--relogin] [--hub <url>] [--label <name>]
|
|
36
|
-
[--agent <ag_xxx> ...] [--cwd <path>]
|
|
36
|
+
[--install-token <dit_xxx>] [--agent <ag_xxx> ...] [--cwd <path>]
|
|
37
37
|
Start the daemon in the foreground by
|
|
38
38
|
default. Pass --background (alias -d)
|
|
39
39
|
to detach and return to the shell.
|
|
@@ -42,6 +42,9 @@ Commands:
|
|
|
42
42
|
first. --hub defaults to ${DEFAULT_HUB}
|
|
43
43
|
(or the URL stored in a previous
|
|
44
44
|
login). --relogin forces re-login.
|
|
45
|
+
--install-token redeems a dashboard
|
|
46
|
+
issued one-time install ticket for
|
|
47
|
+
non-interactive first start.
|
|
45
48
|
--label is sent to the Hub on connect
|
|
46
49
|
for the dashboard device list
|
|
47
50
|
(defaults to hostname). Non-TTY
|
|
@@ -213,6 +216,52 @@ function safeLoadUserAuth() {
|
|
|
213
216
|
function delay(ms) {
|
|
214
217
|
return new Promise((r) => setTimeout(r, ms));
|
|
215
218
|
}
|
|
219
|
+
function parseDaemonTokenResponse(raw, fallbackHubUrl) {
|
|
220
|
+
const obj = (raw && typeof raw === "object" ? raw : {});
|
|
221
|
+
const pick = (camel, snake) => obj[camel] ?? obj[snake];
|
|
222
|
+
const accessToken = pick("accessToken", "access_token");
|
|
223
|
+
const refreshToken = pick("refreshToken", "refresh_token");
|
|
224
|
+
const expiresIn = pick("expiresIn", "expires_in");
|
|
225
|
+
const userId = pick("userId", "user_id");
|
|
226
|
+
const daemonInstanceId = pick("daemonInstanceId", "daemon_instance_id");
|
|
227
|
+
const hubUrl = pick("hubUrl", "hub_url");
|
|
228
|
+
if (typeof accessToken !== "string" || !accessToken) {
|
|
229
|
+
throw new Error("daemon auth response missing accessToken");
|
|
230
|
+
}
|
|
231
|
+
if (typeof refreshToken !== "string" || !refreshToken) {
|
|
232
|
+
throw new Error("daemon auth response missing refreshToken");
|
|
233
|
+
}
|
|
234
|
+
if (typeof userId !== "string" || !userId) {
|
|
235
|
+
throw new Error("daemon auth response missing userId");
|
|
236
|
+
}
|
|
237
|
+
if (typeof daemonInstanceId !== "string" || !daemonInstanceId) {
|
|
238
|
+
throw new Error("daemon auth response missing daemonInstanceId");
|
|
239
|
+
}
|
|
240
|
+
return {
|
|
241
|
+
accessToken,
|
|
242
|
+
refreshToken,
|
|
243
|
+
expiresIn: typeof expiresIn === "number" && expiresIn > 0 ? expiresIn : 3600,
|
|
244
|
+
userId,
|
|
245
|
+
daemonInstanceId,
|
|
246
|
+
hubUrl: typeof hubUrl === "string" && hubUrl.length > 0 ? hubUrl : fallbackHubUrl,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
async function redeemInstallToken(opts) {
|
|
250
|
+
const body = { install_token: opts.installToken };
|
|
251
|
+
if (opts.label)
|
|
252
|
+
body.label = opts.label;
|
|
253
|
+
const resp = await fetch(`${opts.hubUrl.replace(/\/+$/, "")}/daemon/auth/install-token`, {
|
|
254
|
+
method: "POST",
|
|
255
|
+
headers: { "Content-Type": "application/json" },
|
|
256
|
+
body: JSON.stringify(body),
|
|
257
|
+
signal: AbortSignal.timeout(10_000),
|
|
258
|
+
});
|
|
259
|
+
if (!resp.ok) {
|
|
260
|
+
const text = await resp.text().catch(() => "");
|
|
261
|
+
throw new Error(`daemon install-token redeem failed: ${resp.status} ${text}`);
|
|
262
|
+
}
|
|
263
|
+
return parseDaemonTokenResponse(await resp.json(), opts.hubUrl);
|
|
264
|
+
}
|
|
216
265
|
/**
|
|
217
266
|
* Run the device-code login flow against the given Hub. Polls every
|
|
218
267
|
* `interval` seconds (the Hub may bump this) until the user authorizes
|
|
@@ -280,15 +329,16 @@ async function runDeviceCodeFlow(opts) {
|
|
|
280
329
|
* plane (legacy P0 behavior — caller may still log a warning).
|
|
281
330
|
*
|
|
282
331
|
* Decision tree (plan §4.4 + §6.4):
|
|
283
|
-
* 1. `--relogin` →
|
|
284
|
-
* 2.
|
|
285
|
-
* 3.
|
|
332
|
+
* 1. Have existing creds and no `--relogin` → return existing record.
|
|
333
|
+
* 2. `--install-token` → redeem the one-time dashboard ticket.
|
|
334
|
+
* 3. `--relogin` → device-code login.
|
|
286
335
|
* 4. No creds + TTY → device-code login.
|
|
287
336
|
* 5. No creds + no TTY → exit 1 with the §6.4 hint.
|
|
288
337
|
*/
|
|
289
338
|
async function ensureUserAuthForStart(args) {
|
|
290
339
|
const hubFlag = typeof args.flags.hub === "string" ? args.flags.hub : undefined;
|
|
291
340
|
const labelFlag = typeof args.flags.label === "string" ? args.flags.label : undefined;
|
|
341
|
+
const installToken = typeof args.flags["install-token"] === "string" ? args.flags["install-token"] : undefined;
|
|
292
342
|
const relogin = args.flags.relogin === true;
|
|
293
343
|
const existing = safeLoadUserAuth();
|
|
294
344
|
if (!relogin && existing) {
|
|
@@ -305,10 +355,28 @@ async function ensureUserAuthForStart(args) {
|
|
|
305
355
|
if (labelFlag && existing.label !== labelFlag) {
|
|
306
356
|
console.error(`note: --label "${labelFlag}" ignored (already logged in as "${existing.label ?? "<unset>"}"); pass --relogin to change it`);
|
|
307
357
|
}
|
|
358
|
+
if (installToken) {
|
|
359
|
+
console.error("note: --install-token ignored because daemon is already logged in");
|
|
360
|
+
}
|
|
308
361
|
return existing;
|
|
309
362
|
}
|
|
310
363
|
// Need a fresh login. Resolve hubUrl: explicit --hub > existing record > DEFAULT_HUB.
|
|
311
364
|
const hubUrl = hubFlag ?? existing?.hubUrl ?? DEFAULT_HUB;
|
|
365
|
+
const label = labelFlag ?? defaultLoginLabel();
|
|
366
|
+
if (installToken) {
|
|
367
|
+
const tok = await redeemInstallToken({ hubUrl, installToken, label });
|
|
368
|
+
const record = userAuthFromTokenResponse(tok, { label });
|
|
369
|
+
saveUserAuth(record);
|
|
370
|
+
clearAuthExpiredFlag();
|
|
371
|
+
log.info("install-token flow: authorized", {
|
|
372
|
+
userId: record.userId,
|
|
373
|
+
daemonInstanceId: record.daemonInstanceId,
|
|
374
|
+
hubUrl: record.hubUrl,
|
|
375
|
+
label,
|
|
376
|
+
});
|
|
377
|
+
console.log(`Logged in as ${record.userId}`);
|
|
378
|
+
return record;
|
|
379
|
+
}
|
|
312
380
|
if (!process.stdin.isTTY) {
|
|
313
381
|
// Plan §6.4 — non-interactive environment. Fail fast with actionable
|
|
314
382
|
// remediation; never block waiting for input that will never arrive.
|
|
@@ -317,7 +385,6 @@ async function ensureUserAuthForStart(args) {
|
|
|
317
385
|
console.error(" or mount a valid `~/.botcord/daemon/user-auth.json`");
|
|
318
386
|
process.exit(1);
|
|
319
387
|
}
|
|
320
|
-
const label = labelFlag ?? defaultLoginLabel();
|
|
321
388
|
return runDeviceCodeFlow({ hubUrl, label });
|
|
322
389
|
}
|
|
323
390
|
async function cmdStart(args) {
|
package/dist/provision.js
CHANGED
|
@@ -580,9 +580,11 @@ export async function adoptDiscoveredOpenclawAgents(ctx) {
|
|
|
580
580
|
return;
|
|
581
581
|
}
|
|
582
582
|
try {
|
|
583
|
+
const name = resolveOpenclawIdentityName(oc.id, oc.workspace) ?? oc.name ?? `openclaw-${oc.id}`;
|
|
583
584
|
const params = {
|
|
584
585
|
runtime: "openclaw-acp",
|
|
585
|
-
name
|
|
586
|
+
name,
|
|
587
|
+
bio: `OpenClaw agent ${oc.id} adopted from gateway ${gw.name}.`,
|
|
586
588
|
openclaw: { gateway: gw.name, agent: oc.id },
|
|
587
589
|
};
|
|
588
590
|
const credentials = await materializeCredentials(params, freshCfg, {
|
|
@@ -1006,6 +1008,9 @@ function readLocalOpenclawAgents() {
|
|
|
1006
1008
|
row.name = raw.name;
|
|
1007
1009
|
if (typeof raw?.workspace === "string")
|
|
1008
1010
|
row.workspace = raw.workspace;
|
|
1011
|
+
const identityName = resolveOpenclawIdentityName(id, row.workspace, cfg);
|
|
1012
|
+
if (identityName)
|
|
1013
|
+
row.name = identityName;
|
|
1009
1014
|
const m = raw?.model;
|
|
1010
1015
|
if (m && typeof m === "object") {
|
|
1011
1016
|
const model = {};
|
|
@@ -1030,6 +1035,63 @@ function readLocalOpenclawAgents() {
|
|
|
1030
1035
|
return null;
|
|
1031
1036
|
}
|
|
1032
1037
|
}
|
|
1038
|
+
function resolveOpenclawIdentityName(agentId, workspace, cfg) {
|
|
1039
|
+
const root = workspace ?? resolveOpenclawWorkspace(agentId, cfg);
|
|
1040
|
+
if (!root)
|
|
1041
|
+
return undefined;
|
|
1042
|
+
const file = path.join(expandHomePath(root), "IDENTITY.md");
|
|
1043
|
+
try {
|
|
1044
|
+
if (!existsSync(file))
|
|
1045
|
+
return undefined;
|
|
1046
|
+
return parseIdentityName(readFileSync(file, "utf8"));
|
|
1047
|
+
}
|
|
1048
|
+
catch {
|
|
1049
|
+
return undefined;
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
function resolveOpenclawWorkspace(agentId, cfg) {
|
|
1053
|
+
let parsed = cfg;
|
|
1054
|
+
if (!parsed) {
|
|
1055
|
+
try {
|
|
1056
|
+
const file = path.join(homedir(), ".openclaw", "openclaw.json");
|
|
1057
|
+
if (!existsSync(file))
|
|
1058
|
+
return undefined;
|
|
1059
|
+
parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
1060
|
+
}
|
|
1061
|
+
catch {
|
|
1062
|
+
return undefined;
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
const defaults = parsed?.agents?.defaults;
|
|
1066
|
+
const defaultId = typeof defaults?.id === "string" && defaults.id ? defaults.id : "default";
|
|
1067
|
+
if ((agentId === defaultId || agentId === "default") && typeof defaults?.workspace === "string") {
|
|
1068
|
+
return defaults.workspace;
|
|
1069
|
+
}
|
|
1070
|
+
const list = Array.isArray(parsed?.agents?.list) ? parsed.agents.list : [];
|
|
1071
|
+
for (const entry of list) {
|
|
1072
|
+
if (entry?.id === agentId && typeof entry.workspace === "string")
|
|
1073
|
+
return entry.workspace;
|
|
1074
|
+
}
|
|
1075
|
+
return undefined;
|
|
1076
|
+
}
|
|
1077
|
+
function parseIdentityName(raw) {
|
|
1078
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
1079
|
+
const m = line.match(/^\s*-\s*\*\*Name:\*\*\s*(.+?)\s*$/i);
|
|
1080
|
+
if (!m)
|
|
1081
|
+
continue;
|
|
1082
|
+
const name = m[1].trim();
|
|
1083
|
+
if (name && !name.startsWith("_("))
|
|
1084
|
+
return name;
|
|
1085
|
+
}
|
|
1086
|
+
return undefined;
|
|
1087
|
+
}
|
|
1088
|
+
function expandHomePath(p) {
|
|
1089
|
+
if (p === "~")
|
|
1090
|
+
return homedir();
|
|
1091
|
+
if (p.startsWith("~/"))
|
|
1092
|
+
return path.join(homedir(), p.slice(2));
|
|
1093
|
+
return p;
|
|
1094
|
+
}
|
|
1033
1095
|
/**
|
|
1034
1096
|
* Async variant that includes L2 (gateway reachability) and L3 (agent listing)
|
|
1035
1097
|
* probes for runtimes that talk to external services. Used by the production
|
package/package.json
CHANGED
|
@@ -80,6 +80,31 @@ describe("ensureAgentWorkspace", () => {
|
|
|
80
80
|
expect(existsSync(path.join(agentWorkspaceDir("ag_notes"), "notes", ".gitkeep"))).toBe(true);
|
|
81
81
|
});
|
|
82
82
|
|
|
83
|
+
it("seeds bundled Claude Code skills under .claude/skills/", () => {
|
|
84
|
+
ensureAgentWorkspace("ag_skills", {});
|
|
85
|
+
const skillsDir = path.join(agentWorkspaceDir("ag_skills"), ".claude", "skills");
|
|
86
|
+
expect(existsSync(path.join(skillsDir, "botcord", "SKILL.md"))).toBe(true);
|
|
87
|
+
expect(existsSync(path.join(skillsDir, "botcord-user-guide", "SKILL.md"))).toBe(true);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("re-seeds skills on a second call so daemon upgrades propagate", () => {
|
|
91
|
+
ensureAgentWorkspace("ag_skill_upgrade", {});
|
|
92
|
+
const skillFile = path.join(
|
|
93
|
+
agentWorkspaceDir("ag_skill_upgrade"),
|
|
94
|
+
".claude",
|
|
95
|
+
"skills",
|
|
96
|
+
"botcord",
|
|
97
|
+
"SKILL.md",
|
|
98
|
+
);
|
|
99
|
+
writeFileSync(skillFile, "stale content from a prior daemon version\n");
|
|
100
|
+
|
|
101
|
+
ensureAgentWorkspace("ag_skill_upgrade", {});
|
|
102
|
+
|
|
103
|
+
const reseeded = readFileSync(skillFile, "utf8");
|
|
104
|
+
expect(reseeded).not.toBe("stale content from a prior daemon version\n");
|
|
105
|
+
expect(reseeded).toContain("name: botcord");
|
|
106
|
+
});
|
|
107
|
+
|
|
83
108
|
it("does not overwrite a user-modified memory.md on a second call", () => {
|
|
84
109
|
ensureAgentWorkspace("ag_keep", {});
|
|
85
110
|
const memoryPath = path.join(agentWorkspaceDir("ag_keep"), "memory.md");
|
|
@@ -823,7 +823,11 @@ describe("adoptDiscoveredOpenclawAgents", () => {
|
|
|
823
823
|
});
|
|
824
824
|
|
|
825
825
|
expect(res.adopted).toEqual(["ag_adopted"]);
|
|
826
|
-
expect(register).toHaveBeenCalledWith(
|
|
826
|
+
expect(register).toHaveBeenCalledWith(
|
|
827
|
+
"https://hub.example",
|
|
828
|
+
"Main Agent",
|
|
829
|
+
"OpenClaw agent main adopted from gateway local.",
|
|
830
|
+
);
|
|
827
831
|
const saved = JSON.parse(
|
|
828
832
|
fs.readFileSync(nodePath.join(credDir, "ag_adopted.json"), "utf8"),
|
|
829
833
|
) as Record<string, unknown>;
|
|
@@ -882,6 +886,69 @@ describe("adoptDiscoveredOpenclawAgents", () => {
|
|
|
882
886
|
expect(register).not.toHaveBeenCalled();
|
|
883
887
|
});
|
|
884
888
|
});
|
|
889
|
+
|
|
890
|
+
it("uses the OpenClaw workspace identity name when agents.list has no name", async () => {
|
|
891
|
+
await withSandboxHome(async ({ tmp, fs, path: nodePath }) => {
|
|
892
|
+
const credDir = nodePath.join(tmp, ".botcord", "credentials");
|
|
893
|
+
fs.mkdirSync(credDir, { recursive: true });
|
|
894
|
+
fs.writeFileSync(
|
|
895
|
+
nodePath.join(credDir, "ag_seed.json"),
|
|
896
|
+
JSON.stringify({
|
|
897
|
+
version: 1,
|
|
898
|
+
hubUrl: "https://hub.example",
|
|
899
|
+
agentId: "ag_seed",
|
|
900
|
+
keyId: "k_seed",
|
|
901
|
+
privateKey: Buffer.alloc(32, 7).toString("base64"),
|
|
902
|
+
savedAt: new Date().toISOString(),
|
|
903
|
+
}),
|
|
904
|
+
);
|
|
905
|
+
const ocWorkspace = nodePath.join(tmp, ".openclaw", "workspace-swe");
|
|
906
|
+
fs.mkdirSync(ocWorkspace, { recursive: true });
|
|
907
|
+
fs.writeFileSync(
|
|
908
|
+
nodePath.join(ocWorkspace, "IDENTITY.md"),
|
|
909
|
+
["# IDENTITY.md", "", "- **Name:** Danny", "- **Vibe:** ships fast"].join("\n"),
|
|
910
|
+
);
|
|
911
|
+
fs.writeFileSync(
|
|
912
|
+
nodePath.join(tmp, ".openclaw", "openclaw.json"),
|
|
913
|
+
JSON.stringify({
|
|
914
|
+
agents: {
|
|
915
|
+
defaults: { workspace: nodePath.join(tmp, ".openclaw", "workspace") },
|
|
916
|
+
list: [{ id: "swe", workspace: ocWorkspace }],
|
|
917
|
+
},
|
|
918
|
+
}),
|
|
919
|
+
);
|
|
920
|
+
mockState.cfg = {
|
|
921
|
+
defaultRoute: { adapter: "claude-code", cwd: "/tmp" },
|
|
922
|
+
routes: [],
|
|
923
|
+
streamBlocks: true,
|
|
924
|
+
agents: ["ag_seed"],
|
|
925
|
+
openclawGateways: [{ name: "local", url: "ws://127.0.0.1:18789" }],
|
|
926
|
+
};
|
|
927
|
+
|
|
928
|
+
const register = vi.fn(async () => ({
|
|
929
|
+
agentId: "ag_swe",
|
|
930
|
+
keyId: "k_swe",
|
|
931
|
+
privateKey: Buffer.alloc(32, 33).toString("base64"),
|
|
932
|
+
publicKey: Buffer.alloc(32, 34).toString("base64"),
|
|
933
|
+
hubUrl: "https://hub.example",
|
|
934
|
+
token: "tok",
|
|
935
|
+
expiresAt: Date.now() + 60_000,
|
|
936
|
+
}));
|
|
937
|
+
|
|
938
|
+
await adoptDiscoveredOpenclawAgents({
|
|
939
|
+
gateway: makeFakeGateway(["ag_seed"]) as unknown as Parameters<typeof adoptDiscoveredOpenclawAgents>[0]["gateway"],
|
|
940
|
+
register: register as unknown as Parameters<typeof adoptDiscoveredOpenclawAgents>[0]["register"],
|
|
941
|
+
cfg: mockState.cfg as unknown as DaemonConfig,
|
|
942
|
+
probe: async () => ({ ok: true, agents: [{ id: "swe" }] }),
|
|
943
|
+
});
|
|
944
|
+
|
|
945
|
+
expect(register).toHaveBeenCalledWith(
|
|
946
|
+
"https://hub.example",
|
|
947
|
+
"Danny",
|
|
948
|
+
"OpenClaw agent swe adopted from gateway local.",
|
|
949
|
+
);
|
|
950
|
+
});
|
|
951
|
+
});
|
|
885
952
|
});
|
|
886
953
|
|
|
887
954
|
// ---------------------------------------------------------------------------
|
package/src/agent-workspace.ts
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
import {
|
|
20
20
|
chmodSync,
|
|
21
21
|
copyFileSync,
|
|
22
|
+
cpSync,
|
|
22
23
|
existsSync,
|
|
23
24
|
lstatSync,
|
|
24
25
|
mkdirSync,
|
|
@@ -28,9 +29,12 @@ import {
|
|
|
28
29
|
unlinkSync,
|
|
29
30
|
writeFileSync,
|
|
30
31
|
} from "node:fs";
|
|
32
|
+
import { createRequire } from "node:module";
|
|
31
33
|
import { homedir } from "node:os";
|
|
32
34
|
import path from "node:path";
|
|
33
35
|
|
|
36
|
+
const require = createRequire(import.meta.url);
|
|
37
|
+
|
|
34
38
|
// Accepted agent id pattern. Enforced at every path-builder entry so a
|
|
35
39
|
// malicious / malformed agentId (e.g. "../../etc") cannot escape
|
|
36
40
|
// ~/.botcord/agents/ and end up under `rmSync(..., { recursive: true })`
|
|
@@ -364,6 +368,48 @@ export function ensureAgentHermesWorkspace(agentId: string): {
|
|
|
364
368
|
return { hermesHome, hermesWorkspace };
|
|
365
369
|
}
|
|
366
370
|
|
|
371
|
+
/**
|
|
372
|
+
* Bundled Claude Code skills shipped inside `@botcord/cli/skills/`. Seeded
|
|
373
|
+
* into every agent workspace so the spawned `claude` runtime (which loads
|
|
374
|
+
* `.claude/` via `--setting-sources project`) can discover the BotCord CLI
|
|
375
|
+
* skill without any manual setup.
|
|
376
|
+
*/
|
|
377
|
+
const BUNDLED_CC_SKILLS = ["botcord", "botcord-user-guide"] as const;
|
|
378
|
+
|
|
379
|
+
function resolveBundledCliSkillsRoot(): string | null {
|
|
380
|
+
try {
|
|
381
|
+
const pkgJsonPath = require.resolve("@botcord/cli/package.json");
|
|
382
|
+
const root = path.join(path.dirname(pkgJsonPath), "skills");
|
|
383
|
+
return existsSync(root) ? root : null;
|
|
384
|
+
} catch {
|
|
385
|
+
return null;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Copy daemon-owned Claude Code skills into the workspace. Re-copied on every
|
|
391
|
+
* `ensureAgentWorkspace` call (force-overwrite) so daemon upgrades propagate;
|
|
392
|
+
* users wanting custom skills should pick a different directory name under
|
|
393
|
+
* `.claude/skills/` — those are not touched here.
|
|
394
|
+
*/
|
|
395
|
+
function seedClaudeCodeSkills(workspace: string): void {
|
|
396
|
+
const sourceRoot = resolveBundledCliSkillsRoot();
|
|
397
|
+
if (!sourceRoot) return;
|
|
398
|
+
const skillsDir = path.join(workspace, ".claude", "skills");
|
|
399
|
+
mkdirTolerant(path.join(workspace, ".claude"));
|
|
400
|
+
mkdirTolerant(skillsDir);
|
|
401
|
+
for (const name of BUNDLED_CC_SKILLS) {
|
|
402
|
+
const src = path.join(sourceRoot, name);
|
|
403
|
+
if (!existsSync(src)) continue;
|
|
404
|
+
const dst = path.join(skillsDir, name);
|
|
405
|
+
try {
|
|
406
|
+
cpSync(src, dst, { recursive: true, force: true, dereference: true });
|
|
407
|
+
} catch {
|
|
408
|
+
/* best-effort */
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
367
413
|
/**
|
|
368
414
|
* Idempotently create the agent's home / workspace / state directories and
|
|
369
415
|
* seed the workspace Markdown files. Existing files are never overwritten —
|
|
@@ -392,6 +438,7 @@ export function ensureAgentWorkspace(agentId: string, seed: WorkspaceSeed): void
|
|
|
392
438
|
writeIfMissing(path.join(workspace, "memory.md"), MEMORY_MD);
|
|
393
439
|
writeIfMissing(path.join(workspace, "task.md"), TASK_MD);
|
|
394
440
|
writeIfMissing(path.join(notes, ".gitkeep"), "");
|
|
441
|
+
seedClaudeCodeSkills(workspace);
|
|
395
442
|
}
|
|
396
443
|
|
|
397
444
|
/** Patch fields accepted by {@link applyAgentIdentity}. `bio = null` clears it. */
|
|
@@ -510,6 +510,103 @@ describe("createBotCordChannel — streamBlock()", () => {
|
|
|
510
510
|
globalThis.fetch = realFetch;
|
|
511
511
|
}
|
|
512
512
|
});
|
|
513
|
+
|
|
514
|
+
it("normalizes a thinking block with phase/label/source payload", async () => {
|
|
515
|
+
const fetchSpy = vi.fn().mockResolvedValue(new Response(null, { status: 204 }));
|
|
516
|
+
const realFetch = globalThis.fetch;
|
|
517
|
+
globalThis.fetch = fetchSpy as unknown as typeof fetch;
|
|
518
|
+
try {
|
|
519
|
+
const client = makeClient({
|
|
520
|
+
getHubUrl: vi.fn().mockReturnValue("https://hub.example.com"),
|
|
521
|
+
});
|
|
522
|
+
const channel = createBotCordChannel({
|
|
523
|
+
id: "botcord-main",
|
|
524
|
+
accountId: "ag_self",
|
|
525
|
+
agentId: "ag_self",
|
|
526
|
+
client,
|
|
527
|
+
hubBaseUrl: "https://hub.example.com",
|
|
528
|
+
});
|
|
529
|
+
await channel.streamBlock!({
|
|
530
|
+
traceId: "trace_thk",
|
|
531
|
+
accountId: "ag_self",
|
|
532
|
+
conversationId: "rm_oc_1",
|
|
533
|
+
block: {
|
|
534
|
+
kind: "thinking",
|
|
535
|
+
seq: 7,
|
|
536
|
+
raw: { phase: "updated", label: "Searching web", source: "runtime" },
|
|
537
|
+
},
|
|
538
|
+
log: silentLog,
|
|
539
|
+
});
|
|
540
|
+
const body = JSON.parse(fetchSpy.mock.calls[0][1].body as string);
|
|
541
|
+
expect(body.block).toEqual({
|
|
542
|
+
kind: "thinking",
|
|
543
|
+
seq: 7,
|
|
544
|
+
payload: { phase: "updated", label: "Searching web", source: "runtime" },
|
|
545
|
+
});
|
|
546
|
+
} finally {
|
|
547
|
+
globalThis.fetch = realFetch;
|
|
548
|
+
}
|
|
549
|
+
});
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
describe("createBotCordChannel — typing()", () => {
|
|
553
|
+
it("POSTs to /hub/typing with the room id", async () => {
|
|
554
|
+
const fetchSpy = vi.fn().mockResolvedValue(new Response(null, { status: 204 }));
|
|
555
|
+
const realFetch = globalThis.fetch;
|
|
556
|
+
globalThis.fetch = fetchSpy as unknown as typeof fetch;
|
|
557
|
+
try {
|
|
558
|
+
const client = makeClient({
|
|
559
|
+
getHubUrl: vi.fn().mockReturnValue("https://hub.example.com"),
|
|
560
|
+
});
|
|
561
|
+
const channel = createBotCordChannel({
|
|
562
|
+
id: "botcord-main",
|
|
563
|
+
accountId: "ag_self",
|
|
564
|
+
agentId: "ag_self",
|
|
565
|
+
client,
|
|
566
|
+
hubBaseUrl: "https://hub.example.com",
|
|
567
|
+
});
|
|
568
|
+
await channel.typing!({
|
|
569
|
+
traceId: "trace_typ",
|
|
570
|
+
accountId: "ag_self",
|
|
571
|
+
conversationId: "rm_oc_42",
|
|
572
|
+
log: silentLog,
|
|
573
|
+
});
|
|
574
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
575
|
+
const [url, init] = fetchSpy.mock.calls[0];
|
|
576
|
+
expect(url).toBe("https://hub.example.com/hub/typing");
|
|
577
|
+
expect(init.method).toBe("POST");
|
|
578
|
+
const body = JSON.parse(init.body as string);
|
|
579
|
+
expect(body).toEqual({ room_id: "rm_oc_42" });
|
|
580
|
+
expect((init.headers as Record<string, string>).Authorization).toBe("Bearer test-token");
|
|
581
|
+
} finally {
|
|
582
|
+
globalThis.fetch = realFetch;
|
|
583
|
+
}
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
it("swallows fetch failures (fire-and-forget)", async () => {
|
|
587
|
+
const fetchSpy = vi.fn().mockRejectedValue(new Error("network down"));
|
|
588
|
+
const realFetch = globalThis.fetch;
|
|
589
|
+
globalThis.fetch = fetchSpy as unknown as typeof fetch;
|
|
590
|
+
try {
|
|
591
|
+
const channel = createBotCordChannel({
|
|
592
|
+
id: "botcord-main",
|
|
593
|
+
accountId: "ag_self",
|
|
594
|
+
agentId: "ag_self",
|
|
595
|
+
client: makeClient(),
|
|
596
|
+
hubBaseUrl: "https://hub.example.com",
|
|
597
|
+
});
|
|
598
|
+
await expect(
|
|
599
|
+
channel.typing!({
|
|
600
|
+
traceId: "t",
|
|
601
|
+
accountId: "ag_self",
|
|
602
|
+
conversationId: "rm_oc_1",
|
|
603
|
+
log: silentLog,
|
|
604
|
+
}),
|
|
605
|
+
).resolves.toBeUndefined();
|
|
606
|
+
} finally {
|
|
607
|
+
globalThis.fetch = realFetch;
|
|
608
|
+
}
|
|
609
|
+
});
|
|
513
610
|
});
|
|
514
611
|
|
|
515
612
|
// ---------------------------------------------------------------------------
|