@botcord/daemon 0.2.22 → 0.2.24

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.
@@ -1015,6 +1015,40 @@ export class Dispatcher {
1015
1015
  const replyText = (result.text || "").trim();
1016
1016
  const finalTextField = truncateTextField(result.text || "");
1017
1017
  if (!replyText) {
1018
+ if (result.error) {
1019
+ this.log.warn("dispatcher: runtime returned error without reply text", {
1020
+ agentId: msg.accountId,
1021
+ roomId: msg.conversation.id,
1022
+ topicId: msg.conversation.threadId ?? null,
1023
+ turnId,
1024
+ runtime: route.runtime,
1025
+ error: result.error,
1026
+ });
1027
+ if (isOwnerChat) {
1028
+ const sendResult = await this.sendReply(channel, {
1029
+ channel: msg.channel,
1030
+ accountId: msg.accountId,
1031
+ conversationId: msg.conversation.id,
1032
+ threadId: msg.conversation.threadId ?? null,
1033
+ text: `⚠️ Runtime error: ${truncate(result.error, 500)}`,
1034
+ replyTo: msg.id,
1035
+ traceId: msg.trace?.id ?? null,
1036
+ }, turnId);
1037
+ this.emitOutbound({
1038
+ turnId,
1039
+ msg,
1040
+ runtime: route.runtime,
1041
+ runtimeSessionId: result.newSessionId || null,
1042
+ startedAt: slot.dispatchedAt,
1043
+ costUsd: result.costUsd,
1044
+ finalText: finalTextField,
1045
+ deliveryStatus: sendResult.ok ? "delivered" : "send_failed",
1046
+ deliveryReason: sendResult.ok ? null : sendResult.error,
1047
+ blocks: slot.blocks,
1048
+ });
1049
+ return;
1050
+ }
1051
+ }
1018
1052
  this.emitOutbound({
1019
1053
  turnId,
1020
1054
  msg,
@@ -1024,7 +1058,7 @@ export class Dispatcher {
1024
1058
  costUsd: result.costUsd,
1025
1059
  finalText: finalTextField,
1026
1060
  deliveryStatus: "empty_text",
1027
- deliveryReason: null,
1061
+ deliveryReason: result.error ?? null,
1028
1062
  blocks: slot.blocks,
1029
1063
  });
1030
1064
  return;
@@ -97,11 +97,9 @@ export class OpenclawAcpAdapter {
97
97
  if (!gateway) {
98
98
  return failResult(opts.sessionId ?? "", "openclaw-acp: missing gateway endpoint (route.gateway not resolved)");
99
99
  }
100
- if (!gateway.openclawAgent) {
101
- return failResult(opts.sessionId ?? "", `openclaw-acp: gateway "${gateway.name}" did not resolve an openclawAgent (set defaultAgent on the profile or openclawAgent on the route)`);
102
- }
100
+ const openclawAgent = gateway.openclawAgent ?? "default";
103
101
  const sessionKey = buildAcpSessionKey({
104
- openclawAgent: gateway.openclawAgent,
102
+ openclawAgent,
105
103
  accountId: opts.accountId,
106
104
  // The dispatcher passes `context.conversationKey` in for routing;
107
105
  // fall back to a stable per-accountId key when it's not present (e.g.
package/dist/provision.js CHANGED
@@ -189,7 +189,9 @@ async function provisionAgent(params, ctx) {
189
189
  // that hole by moving the check to the union of both.
190
190
  const explicitCwd = params.credentials?.cwd ?? params.cwd;
191
191
  assertSafeCwd(explicitCwd);
192
- const openclawSel = pickOpenclawSelection(params);
192
+ const initialCfg = loadConfig();
193
+ const openclawSel = await resolveProvisionOpenclawSelection(params, initialCfg);
194
+ const resolvedParams = withResolvedOpenclawSelection(params, openclawSel);
193
195
  if (openclawSel.gateway && openclawSel.agent) {
194
196
  return withOpenclawProvisionLock(openclawSel.gateway, openclawSel.agent, async () => {
195
197
  const existing = findCredentialsByOpenclaw(openclawSel.gateway, openclawSel.agent);
@@ -202,7 +204,7 @@ async function provisionAgent(params, ctx) {
202
204
  return installExistingOpenclawBinding(existing.agentId, ctx);
203
205
  }
204
206
  const cfg = loadConfig();
205
- const credentials = await materializeCredentials(params, cfg, ctx, explicitCwd);
207
+ const credentials = await materializeCredentials(resolvedParams, cfg, ctx, explicitCwd);
206
208
  return installLocalAgent(credentials, {
207
209
  ...ctx,
208
210
  cfg,
@@ -211,11 +213,10 @@ async function provisionAgent(params, ctx) {
211
213
  });
212
214
  });
213
215
  }
214
- const cfg = loadConfig();
215
- const credentials = await materializeCredentials(params, cfg, ctx, explicitCwd);
216
+ const credentials = await materializeCredentials(resolvedParams, initialCfg, ctx, explicitCwd);
216
217
  return installLocalAgent(credentials, {
217
218
  ...ctx,
218
- cfg,
219
+ cfg: initialCfg,
219
220
  bio: params.bio,
220
221
  source: params.credentials ? "hub-supplied" : "registered",
221
222
  });
@@ -542,6 +543,59 @@ function pickOpenclawSelection(params) {
542
543
  }
543
544
  return out;
544
545
  }
546
+ async function resolveProvisionOpenclawSelection(params, cfg) {
547
+ const out = pickOpenclawSelection(params);
548
+ if (!out.gateway || out.agent)
549
+ return out;
550
+ const profile = (cfg.openclawGateways ?? []).find((g) => g.name === out.gateway);
551
+ if (!profile)
552
+ return out;
553
+ const prepared = prepareGatewayProfile(profile);
554
+ if (prepared.defaultAgent) {
555
+ out.agent = prepared.defaultAgent;
556
+ return out;
557
+ }
558
+ if (isLoopbackUrl(prepared.url)) {
559
+ const localAgents = readLocalOpenclawAgents();
560
+ const defaultAgent = localAgents?.find((a) => a.id === "default") ?? (localAgents?.length === 1 ? localAgents[0] : undefined);
561
+ if (defaultAgent) {
562
+ out.agent = defaultAgent.id;
563
+ return out;
564
+ }
565
+ }
566
+ try {
567
+ const probeResult = await probeOpenclawAgents(prepared);
568
+ if (probeResult.ok) {
569
+ const agents = probeResult.agents ?? [];
570
+ const defaultAgent = agents.find((a) => a.id === "default") ?? (agents.length === 1 ? agents[0] : undefined);
571
+ if (defaultAgent) {
572
+ out.agent = defaultAgent.id;
573
+ return out;
574
+ }
575
+ }
576
+ }
577
+ catch (err) {
578
+ daemonLog.debug("provision_agent: openclaw default probe failed", {
579
+ gateway: out.gateway,
580
+ error: err instanceof Error ? err.message : String(err),
581
+ });
582
+ }
583
+ if (isLoopbackUrl(prepared.url))
584
+ out.agent = "default";
585
+ return out;
586
+ }
587
+ function withResolvedOpenclawSelection(params, selection) {
588
+ if (!selection.gateway)
589
+ return params;
590
+ return {
591
+ ...params,
592
+ openclaw: {
593
+ ...(params.openclaw ?? {}),
594
+ gateway: selection.gateway,
595
+ ...(selection.agent ? { agent: selection.agent } : {}),
596
+ },
597
+ };
598
+ }
545
599
  async function withOpenclawProvisionLock(gateway, agent, fn) {
546
600
  const key = `${gateway}\0${agent}`;
547
601
  const prev = openclawProvisionLocks.get(key) ?? Promise.resolve();
@@ -1068,7 +1122,7 @@ function readLocalOpenclawAgents() {
1068
1122
  try {
1069
1123
  const file = path.join(homedir(), ".openclaw", "openclaw.json");
1070
1124
  if (!existsSync(file))
1071
- return null;
1125
+ return [{ id: "default" }];
1072
1126
  const cfg = JSON.parse(readFileSync(file, "utf8"));
1073
1127
  const list = Array.isArray(cfg?.agents?.list) ? cfg.agents.list : [];
1074
1128
  const defaultId = typeof cfg?.agents?.defaults?.id === "string" ? cfg.agents.defaults.id : "default";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botcord/daemon",
3
- "version": "0.2.22",
3
+ "version": "0.2.24",
4
4
  "description": "BotCord local daemon — bridges Hub inbox push to local Claude Code / Codex / Gemini CLIs",
5
5
  "type": "module",
6
6
  "bin": {
@@ -69,12 +69,26 @@ describe("OpenclawAcpAdapter.run", () => {
69
69
  expect(res.error).toMatch(/missing gateway/);
70
70
  });
71
71
 
72
- it("fails when gateway has no openclawAgent resolved", async () => {
73
- const adapter = new OpenclawAcpAdapter({ spawnFn: makeSpawn(new FakeChild()) });
72
+ it("defaults to OpenClaw's default agent when gateway has no openclawAgent resolved", async () => {
73
+ const child = new FakeChild();
74
+ const adapter = new OpenclawAcpAdapter({ spawnFn: makeSpawn(child) });
74
75
  const gateway: ResolvedOpenclawGateway = {
75
76
  name: "local",
76
77
  url: "ws://127.0.0.1:1",
77
78
  };
79
+ child.stdin.on("data", (chunk: Buffer) => {
80
+ for (const line of chunk.toString("utf8").split("\n").filter(Boolean)) {
81
+ const frame = JSON.parse(line);
82
+ if (frame.method === "initialize") {
83
+ child.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: frame.id, result: { protocolVersion: 1 } }) + "\n");
84
+ } else if (frame.method === "session/new") {
85
+ expect(frame.params._meta.sessionKey).toContain("agent:default:");
86
+ child.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: frame.id, result: { sessionId: "sid-default" } }) + "\n");
87
+ } else if (frame.method === "session/prompt") {
88
+ child.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: frame.id, result: { text: "ok" } }) + "\n");
89
+ }
90
+ }
91
+ });
78
92
  const res = await adapter.run({
79
93
  text: "hi",
80
94
  sessionId: null,
@@ -84,7 +98,7 @@ describe("OpenclawAcpAdapter.run", () => {
84
98
  trustLevel: "owner",
85
99
  gateway,
86
100
  });
87
- expect(res.error).toMatch(/openclawAgent/);
101
+ expect(res.text).toBe("ok");
88
102
  });
89
103
 
90
104
  it("performs initialize → newSession → prompt and returns final text", async () => {
@@ -585,6 +585,45 @@ describe("provision_agent seeds workspace + hot-adds managed route", () => {
585
585
  });
586
586
  });
587
587
 
588
+ it("binds OpenClaw default agent when provisioning only specifies a loopback gateway", async () => {
589
+ await withSandboxHome(async ({ tmp, fs, path: nodePath }) => {
590
+ mockState.cfg = {
591
+ defaultRoute: { adapter: "claude-code", cwd: "/tmp" },
592
+ routes: [],
593
+ streamBlocks: true,
594
+ openclawGateways: [{ name: "local", url: "ws://127.0.0.1:18789" }],
595
+ };
596
+ const gw = makeFakeGateway();
597
+ const provisioner = createProvisioner({
598
+ gateway: gw as unknown as Parameters<typeof createProvisioner>[0]["gateway"],
599
+ });
600
+ const privateKey = Buffer.alloc(32, 14).toString("base64");
601
+ const ack = await provisioner({
602
+ id: "req_openclaw_default",
603
+ type: CONTROL_FRAME_TYPES.PROVISION_AGENT,
604
+ params: {
605
+ runtime: "openclaw-acp",
606
+ openclaw: { gateway: "local" },
607
+ credentials: {
608
+ agentId: "ag_openclaw_default",
609
+ keyId: "k_ocd",
610
+ privateKey,
611
+ hubUrl: "https://hub.example",
612
+ },
613
+ },
614
+ });
615
+
616
+ expect(ack.ok).toBe(true);
617
+ const credFile = nodePath.join(tmp, ".botcord", "credentials", "ag_openclaw_default.json");
618
+ const saved = JSON.parse(fs.readFileSync(credFile, "utf8")) as Record<string, unknown>;
619
+ expect(saved.openclawGateway).toBe("local");
620
+ expect(saved.openclawAgent).toBe("default");
621
+ const route = gw.listManagedRoutes().find((r) => r.match?.accountId === "ag_openclaw_default");
622
+ expect(route?.gateway?.name).toBe("local");
623
+ expect(route?.gateway?.openclawAgent).toBe("default");
624
+ });
625
+ });
626
+
588
627
  it("defaults cwd to agentWorkspaceDir on the slow path (daemon register)", async () => {
589
628
  await withSandboxHome(async ({ tmp, fs, path: nodePath }) => {
590
629
  // Seed an existing credential so `inferHubUrl` finds a hubUrl.
@@ -430,6 +430,18 @@ describe("Dispatcher", () => {
430
430
  expect(store.all().length).toBe(0);
431
431
  });
432
432
 
433
+ it("runtime empty text with error: sends owner-chat error reply", async () => {
434
+ const runtime = new FakeRuntime({ reply: "", newSessionId: "", errorText: "missing openclawAgent" });
435
+ const channel = new FakeChannel();
436
+ const { dispatcher } = await scaffold({ channel, runtimeFactory: () => runtime });
437
+
438
+ await dispatcher.handle(makeEnvelope({ id: "msg_error" }));
439
+
440
+ expect(channel.sends.length).toBe(1);
441
+ expect(channel.sends[0].message.text).toContain("Runtime error");
442
+ expect(channel.sends[0].message.text).toContain("missing openclawAgent");
443
+ });
444
+
433
445
  it("cancel-previous: prior turn is aborted and does not write session, new turn writes", async () => {
434
446
  const prior = new FakeRuntime({ hang: true, newSessionId: "prior-sid" });
435
447
  const newer = new FakeRuntime({ reply: "newer", newSessionId: "newer-sid" });
@@ -1228,6 +1228,40 @@ export class Dispatcher {
1228
1228
  const finalTextField = truncateTextField(result.text || "");
1229
1229
 
1230
1230
  if (!replyText) {
1231
+ if (result.error) {
1232
+ this.log.warn("dispatcher: runtime returned error without reply text", {
1233
+ agentId: msg.accountId,
1234
+ roomId: msg.conversation.id,
1235
+ topicId: msg.conversation.threadId ?? null,
1236
+ turnId,
1237
+ runtime: route.runtime,
1238
+ error: result.error,
1239
+ });
1240
+ if (isOwnerChat) {
1241
+ const sendResult = await this.sendReply(channel, {
1242
+ channel: msg.channel,
1243
+ accountId: msg.accountId,
1244
+ conversationId: msg.conversation.id,
1245
+ threadId: msg.conversation.threadId ?? null,
1246
+ text: `⚠️ Runtime error: ${truncate(result.error, 500)}`,
1247
+ replyTo: msg.id,
1248
+ traceId: msg.trace?.id ?? null,
1249
+ }, turnId);
1250
+ this.emitOutbound({
1251
+ turnId,
1252
+ msg,
1253
+ runtime: route.runtime,
1254
+ runtimeSessionId: result.newSessionId || null,
1255
+ startedAt: slot.dispatchedAt,
1256
+ costUsd: result.costUsd,
1257
+ finalText: finalTextField,
1258
+ deliveryStatus: sendResult.ok ? "delivered" : "send_failed",
1259
+ deliveryReason: sendResult.ok ? null : sendResult.error,
1260
+ blocks: slot.blocks,
1261
+ });
1262
+ return;
1263
+ }
1264
+ }
1231
1265
  this.emitOutbound({
1232
1266
  turnId,
1233
1267
  msg,
@@ -1237,7 +1271,7 @@ export class Dispatcher {
1237
1271
  costUsd: result.costUsd,
1238
1272
  finalText: finalTextField,
1239
1273
  deliveryStatus: "empty_text",
1240
- deliveryReason: null,
1274
+ deliveryReason: result.error ?? null,
1241
1275
  blocks: slot.blocks,
1242
1276
  });
1243
1277
  return;
@@ -169,14 +169,9 @@ export class OpenclawAcpAdapter implements RuntimeAdapter {
169
169
  "openclaw-acp: missing gateway endpoint (route.gateway not resolved)",
170
170
  );
171
171
  }
172
- if (!gateway.openclawAgent) {
173
- return failResult(
174
- opts.sessionId ?? "",
175
- `openclaw-acp: gateway "${gateway.name}" did not resolve an openclawAgent (set defaultAgent on the profile or openclawAgent on the route)`,
176
- );
177
- }
172
+ const openclawAgent = gateway.openclawAgent ?? "default";
178
173
  const sessionKey = buildAcpSessionKey({
179
- openclawAgent: gateway.openclawAgent,
174
+ openclawAgent,
180
175
  accountId: opts.accountId,
181
176
  // The dispatcher passes `context.conversationKey` in for routing;
182
177
  // fall back to a stable per-accountId key when it's not present (e.g.
package/src/provision.ts CHANGED
@@ -316,7 +316,9 @@ async function provisionAgent(
316
316
  const explicitCwd = params.credentials?.cwd ?? params.cwd;
317
317
  assertSafeCwd(explicitCwd);
318
318
 
319
- const openclawSel = pickOpenclawSelection(params);
319
+ const initialCfg = loadConfig();
320
+ const openclawSel = await resolveProvisionOpenclawSelection(params, initialCfg);
321
+ const resolvedParams = withResolvedOpenclawSelection(params, openclawSel);
320
322
  if (openclawSel.gateway && openclawSel.agent) {
321
323
  return withOpenclawProvisionLock(openclawSel.gateway, openclawSel.agent, async () => {
322
324
  const existing = findCredentialsByOpenclaw(openclawSel.gateway!, openclawSel.agent!);
@@ -329,7 +331,7 @@ async function provisionAgent(
329
331
  return installExistingOpenclawBinding(existing.agentId, ctx);
330
332
  }
331
333
  const cfg = loadConfig();
332
- const credentials = await materializeCredentials(params, cfg, ctx, explicitCwd);
334
+ const credentials = await materializeCredentials(resolvedParams, cfg, ctx, explicitCwd);
333
335
  return installLocalAgent(credentials, {
334
336
  ...ctx,
335
337
  cfg,
@@ -339,11 +341,10 @@ async function provisionAgent(
339
341
  });
340
342
  }
341
343
 
342
- const cfg = loadConfig();
343
- const credentials = await materializeCredentials(params, cfg, ctx, explicitCwd);
344
+ const credentials = await materializeCredentials(resolvedParams, initialCfg, ctx, explicitCwd);
344
345
  return installLocalAgent(credentials, {
345
346
  ...ctx,
346
- cfg,
347
+ cfg: initialCfg,
347
348
  bio: params.bio,
348
349
  source: params.credentials ? "hub-supplied" : "registered",
349
350
  });
@@ -688,6 +689,67 @@ function pickOpenclawSelection(
688
689
  return out;
689
690
  }
690
691
 
692
+ async function resolveProvisionOpenclawSelection(
693
+ params: ProvisionAgentParams,
694
+ cfg: DaemonConfig,
695
+ ): Promise<{ gateway?: string; agent?: string }> {
696
+ const out = pickOpenclawSelection(params);
697
+ if (!out.gateway || out.agent) return out;
698
+
699
+ const profile = (cfg.openclawGateways ?? []).find((g) => g.name === out.gateway);
700
+ if (!profile) return out;
701
+
702
+ const prepared = prepareGatewayProfile(profile);
703
+ if (prepared.defaultAgent) {
704
+ out.agent = prepared.defaultAgent;
705
+ return out;
706
+ }
707
+
708
+ if (isLoopbackUrl(prepared.url)) {
709
+ const localAgents = readLocalOpenclawAgents();
710
+ const defaultAgent = localAgents?.find((a) => a.id === "default") ?? (localAgents?.length === 1 ? localAgents[0] : undefined);
711
+ if (defaultAgent) {
712
+ out.agent = defaultAgent.id;
713
+ return out;
714
+ }
715
+ }
716
+
717
+ try {
718
+ const probeResult = await probeOpenclawAgents(prepared);
719
+ if (probeResult.ok) {
720
+ const agents = probeResult.agents ?? [];
721
+ const defaultAgent = agents.find((a) => a.id === "default") ?? (agents.length === 1 ? agents[0] : undefined);
722
+ if (defaultAgent) {
723
+ out.agent = defaultAgent.id;
724
+ return out;
725
+ }
726
+ }
727
+ } catch (err) {
728
+ daemonLog.debug("provision_agent: openclaw default probe failed", {
729
+ gateway: out.gateway,
730
+ error: err instanceof Error ? err.message : String(err),
731
+ });
732
+ }
733
+
734
+ if (isLoopbackUrl(prepared.url)) out.agent = "default";
735
+ return out;
736
+ }
737
+
738
+ function withResolvedOpenclawSelection(
739
+ params: ProvisionAgentParams,
740
+ selection: { gateway?: string; agent?: string },
741
+ ): ProvisionAgentParams {
742
+ if (!selection.gateway) return params;
743
+ return {
744
+ ...params,
745
+ openclaw: {
746
+ ...(params.openclaw ?? {}),
747
+ gateway: selection.gateway,
748
+ ...(selection.agent ? { agent: selection.agent } : {}),
749
+ },
750
+ };
751
+ }
752
+
691
753
  async function withOpenclawProvisionLock<T>(
692
754
  gateway: string,
693
755
  agent: string,
@@ -1294,7 +1356,7 @@ function readLocalOpenclawAgents(): Array<{
1294
1356
  }> | null {
1295
1357
  try {
1296
1358
  const file = path.join(homedir(), ".openclaw", "openclaw.json");
1297
- if (!existsSync(file)) return null;
1359
+ if (!existsSync(file)) return [{ id: "default" }];
1298
1360
  const cfg = JSON.parse(readFileSync(file, "utf8")) as any;
1299
1361
  const list = Array.isArray(cfg?.agents?.list) ? cfg.agents.list : [];
1300
1362
  const defaultId = typeof cfg?.agents?.defaults?.id === "string" ? cfg.agents.defaults.id : "default";