@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.
Files changed (34) hide show
  1. package/dist/agent-workspace.js +47 -1
  2. package/dist/gateway/channels/botcord.js +39 -0
  3. package/dist/gateway/dispatcher.d.ts +6 -0
  4. package/dist/gateway/dispatcher.js +207 -9
  5. package/dist/gateway/runtimes/acp-stream.d.ts +7 -1
  6. package/dist/gateway/runtimes/acp-stream.js +19 -0
  7. package/dist/gateway/runtimes/claude-code.js +34 -0
  8. package/dist/gateway/runtimes/codex.js +50 -0
  9. package/dist/gateway/runtimes/hermes-agent.d.ts +8 -3
  10. package/dist/gateway/runtimes/hermes-agent.js +36 -6
  11. package/dist/gateway/runtimes/ndjson-stream.d.ts +8 -1
  12. package/dist/gateway/runtimes/ndjson-stream.js +8 -0
  13. package/dist/gateway/types.d.ts +54 -2
  14. package/dist/index.js +72 -5
  15. package/dist/provision.js +63 -1
  16. package/package.json +1 -1
  17. package/src/__tests__/agent-workspace.test.ts +25 -0
  18. package/src/__tests__/provision.test.ts +68 -1
  19. package/src/agent-workspace.ts +47 -0
  20. package/src/gateway/__tests__/botcord-channel.test.ts +97 -0
  21. package/src/gateway/__tests__/claude-code-adapter.test.ts +35 -0
  22. package/src/gateway/__tests__/codex-adapter.test.ts +44 -0
  23. package/src/gateway/__tests__/dispatcher.test.ts +552 -1
  24. package/src/gateway/__tests__/hermes-agent-adapter.test.ts +39 -0
  25. package/src/gateway/channels/botcord.ts +38 -0
  26. package/src/gateway/dispatcher.ts +217 -15
  27. package/src/gateway/runtimes/acp-stream.ts +24 -0
  28. package/src/gateway/runtimes/claude-code.ts +41 -1
  29. package/src/gateway/runtimes/codex.ts +58 -0
  30. package/src/gateway/runtimes/hermes-agent.ts +45 -5
  31. package/src/gateway/runtimes/ndjson-stream.ts +15 -0
  32. package/src/gateway/types.ts +55 -2
  33. package/src/index.ts +88 -5
  34. 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) {
@@ -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
- /** Normalized kind, used by channels to decide whether to forward progressive output. */
216
- kind: "assistant_text" | "tool_use" | "tool_result" | "system" | "other";
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` → device-code login.
284
- * 2. Have valid creds (not near expiry) → return existing record.
285
- * 3. Have stale creds leave as-is; the control channel will refresh.
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: oc.name ?? `openclaw-${oc.id}`,
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botcord/daemon",
3
- "version": "0.2.13",
3
+ "version": "0.2.15",
4
4
  "description": "BotCord local daemon — bridges Hub inbox push to local Claude Code / Codex / Gemini CLIs",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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("https://hub.example", "Main Agent", undefined);
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
  // ---------------------------------------------------------------------------
@@ -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
  // ---------------------------------------------------------------------------