@botcord/daemon 0.2.50 → 0.2.52

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.
@@ -768,6 +768,8 @@ function normalizeBlockForHub(block, seq) {
768
768
  if (kind === "assistant_text") {
769
769
  // Claude Code: {type:"assistant", message:{content:[{type:"text",text}]}}
770
770
  // Codex: {type:"item.completed", item:{type:"agent_message", text}}
771
+ // DeepSeek: {event:"message.delta", payload:{content}} or
772
+ // {event:"item.delta", payload:{payload:{kind:"agent_message", delta}}}
771
773
  let text = "";
772
774
  const contents = Array.isArray(raw?.message?.content) ? raw.message.content : [];
773
775
  for (const c of contents) {
@@ -776,6 +778,15 @@ function normalizeBlockForHub(block, seq) {
776
778
  }
777
779
  if (!text && typeof raw?.item?.text === "string")
778
780
  text = raw.item.text;
781
+ if (!text && raw?.event === "message.delta" && typeof raw?.payload?.content === "string") {
782
+ text = raw.payload.content;
783
+ }
784
+ if (!text &&
785
+ raw?.event === "item.delta" &&
786
+ raw?.payload?.payload?.kind === "agent_message" &&
787
+ typeof raw?.payload?.payload?.delta === "string") {
788
+ text = raw.payload.payload.delta;
789
+ }
779
790
  return { kind: "assistant", seq, payload: { text } };
780
791
  }
781
792
  if (kind === "tool_use") {
@@ -8,7 +8,7 @@ export declare function probeKimi(deps?: ProbeDeps): RuntimeProbeResult;
8
8
  /**
9
9
  * Kimi CLI adapter — spawns:
10
10
  *
11
- * kimi --work-dir <cwd> --print --output-format stream-json --session <sid> --prompt <text>
11
+ * kimi --work-dir <cwd> --print --output-format stream-json --session <sid> --afk --prompt <text>
12
12
  *
13
13
  * `--session <sid>` resumes an existing session or creates a new session with
14
14
  * that id, so the adapter generates a UUID on first turn and persists it for
@@ -17,6 +17,108 @@ function isValidKimiSessionId(sessionId) {
17
17
  function invalidKimiSessionIdError() {
18
18
  return "kimi-cli: invalid sessionId (expected non-control text not starting with '-')";
19
19
  }
20
+ const KIMI_EXTRA_FLAGS_WITH_VALUE = new Set([
21
+ "--add-dir",
22
+ "--agent",
23
+ "--agent-file",
24
+ "--config",
25
+ "--config-file",
26
+ "--max-ralph-iterations",
27
+ "--max-retries-per-step",
28
+ "--max-steps-per-turn",
29
+ "--mcp-config",
30
+ "--mcp-config-file",
31
+ "--model",
32
+ "--skills-dir",
33
+ "-m",
34
+ ]);
35
+ const KIMI_EXTRA_BOOLEAN_FLAGS = new Set([
36
+ "--afk",
37
+ "--auto-approve",
38
+ "--debug",
39
+ "--no-thinking",
40
+ "--plan",
41
+ "--thinking",
42
+ "--verbose",
43
+ "--yes",
44
+ "--yolo",
45
+ "-y",
46
+ ]);
47
+ // Flags owned by the adapter because BotCord depends on Kimi's non-interactive
48
+ // stream-json contract, cwd isolation, prompt placement, and session routing.
49
+ const KIMI_ADAPTER_OWNED_FLAGS = new Set([
50
+ "--acp",
51
+ "--command",
52
+ "--continue",
53
+ "--final-message-only",
54
+ "--help",
55
+ "--input-format",
56
+ "--output-format",
57
+ "--print",
58
+ "--prompt",
59
+ "--quiet",
60
+ "--resume",
61
+ "--session",
62
+ "--version",
63
+ "--wire",
64
+ "--work-dir",
65
+ "-C",
66
+ "-S",
67
+ "-V",
68
+ "-c",
69
+ "-h",
70
+ "-p",
71
+ "-r",
72
+ "-w",
73
+ ]);
74
+ function flagName(arg) {
75
+ if (!arg.startsWith("-"))
76
+ return arg;
77
+ const eq = arg.indexOf("=");
78
+ return eq === -1 ? arg : arg.slice(0, eq);
79
+ }
80
+ function nextValue(args, index) {
81
+ const next = args[index + 1];
82
+ if (typeof next !== "string")
83
+ return undefined;
84
+ if (!next.startsWith("-"))
85
+ return next;
86
+ return /^-\d/.test(next) ? next : undefined;
87
+ }
88
+ function sanitizeKimiExtraArgs(extraArgs) {
89
+ if (!extraArgs?.length)
90
+ return [];
91
+ const out = [];
92
+ for (let i = 0; i < extraArgs.length; i += 1) {
93
+ const arg = extraArgs[i];
94
+ const name = flagName(arg);
95
+ if (KIMI_ADAPTER_OWNED_FLAGS.has(name)) {
96
+ if (!arg.includes("=") && nextValue(extraArgs, i) !== undefined)
97
+ i += 1;
98
+ continue;
99
+ }
100
+ if (KIMI_EXTRA_FLAGS_WITH_VALUE.has(name)) {
101
+ if (arg.includes("=")) {
102
+ out.push(arg);
103
+ continue;
104
+ }
105
+ const value = nextValue(extraArgs, i);
106
+ if (value !== undefined) {
107
+ out.push(arg, value);
108
+ i += 1;
109
+ }
110
+ continue;
111
+ }
112
+ if (KIMI_EXTRA_BOOLEAN_FLAGS.has(name)) {
113
+ out.push(arg);
114
+ continue;
115
+ }
116
+ if (arg.startsWith("-") && !arg.includes("=") && nextValue(extraArgs, i) !== undefined) {
117
+ i += 1;
118
+ }
119
+ }
120
+ return out;
121
+ }
20
122
  /** Resolve the Kimi CLI executable on PATH. */
21
123
  export function resolveKimiCommand(deps = {}) {
22
124
  return resolveCommandOnPath("kimi", deps);
@@ -35,7 +137,7 @@ export function probeKimi(deps = {}) {
35
137
  /**
36
138
  * Kimi CLI adapter — spawns:
37
139
  *
38
- * kimi --work-dir <cwd> --print --output-format stream-json --session <sid> --prompt <text>
140
+ * kimi --work-dir <cwd> --print --output-format stream-json --session <sid> --afk --prompt <text>
39
141
  *
40
142
  * `--session <sid>` resumes an existing session or creates a new session with
41
143
  * that id, so the adapter generates a UUID on first turn and persists it for
@@ -83,8 +185,7 @@ export class KimiAdapter extends NdjsonStreamAdapter {
83
185
  sessionId,
84
186
  "--afk",
85
187
  ];
86
- if (opts.extraArgs?.length)
87
- args.push(...opts.extraArgs);
188
+ args.push(...sanitizeKimiExtraArgs(opts.extraArgs));
88
189
  args.push("--prompt", promptWithSystemContext(opts.text, opts.systemContext));
89
190
  return args;
90
191
  }
@@ -3,8 +3,8 @@ import { homedir } from "node:os";
3
3
  import path from "node:path";
4
4
  import { log as daemonLog } from "./log.js";
5
5
  import { probeOpenclawAgents } from "./provision.js";
6
- const DEFAULT_SEARCH_PATHS = ["~/.openclaw/", "/etc/openclaw/"];
7
- const DEFAULT_PORTS = [18789, 16200];
6
+ const DEFAULT_SEARCH_PATHS = ["~/.openclaw/", "~/.qclaw/", "/etc/openclaw/"];
7
+ const DEFAULT_PORTS = [18789, 16200, 28789];
8
8
  const DEFAULT_TOKEN_FILE_PATHS = [
9
9
  "/run/openclaw/gateway-token",
10
10
  "/var/run/openclaw/gateway-token",
@@ -346,6 +346,9 @@ function discoverFromConfigDir(root) {
346
346
  }
347
347
  function parseJsonConfig(raw) {
348
348
  const obj = JSON.parse(raw);
349
+ const qclaw = pickQclawGatewayValues(obj);
350
+ if (qclaw)
351
+ return qclaw;
349
352
  // Prefer OpenClaw's native shape: `gateway.port` + `gateway.auth.token`.
350
353
  // The legacy `acp.url` shape is also supported for explicit user-authored configs.
351
354
  const native = pickOpenclawGatewayValues(obj?.gateway);
@@ -354,6 +357,34 @@ function parseJsonConfig(raw) {
354
357
  const acp = obj?.acp ?? obj?.gateway?.acp ?? obj?.gateway ?? obj;
355
358
  return pickConfigValues(acp);
356
359
  }
360
+ function pickQclawGatewayValues(obj) {
361
+ if (!obj || typeof obj !== "object")
362
+ return null;
363
+ const port = typeof obj.port === "number" ? obj.port : undefined;
364
+ const configPath = typeof obj.configPath === "string" && obj.configPath.trim()
365
+ ? obj.configPath.trim()
366
+ : undefined;
367
+ if (!port && !configPath)
368
+ return null;
369
+ const fromConfig = configPath ? readGatewayValuesFromConfigPath(configPath) : null;
370
+ if (fromConfig)
371
+ return fromConfig;
372
+ if (!port)
373
+ return null;
374
+ return { url: `ws://127.0.0.1:${port}` };
375
+ }
376
+ function readGatewayValuesFromConfigPath(configPath) {
377
+ try {
378
+ const raw = readFileSync(expandHome(configPath), "utf8");
379
+ const parsed = parseJsonConfig(raw);
380
+ if (parsed?.url)
381
+ return parsed;
382
+ }
383
+ catch {
384
+ // qclaw.json may be copied without its referenced openclaw.json.
385
+ }
386
+ return null;
387
+ }
357
388
  function pickOpenclawGatewayValues(gw) {
358
389
  if (!gw || typeof gw !== "object")
359
390
  return null;
package/dist/provision.js CHANGED
@@ -1075,10 +1075,10 @@ function localOpenclawAcpDisabled(rawUrl) {
1075
1075
  if (!isLoopbackUrl(rawUrl))
1076
1076
  return false;
1077
1077
  try {
1078
- const file = path.join(homedir(), ".openclaw", "openclaw.json");
1079
- if (!existsSync(file))
1078
+ const source = pickLocalOpenclawConfig(rawUrl);
1079
+ if (!source)
1080
1080
  return false;
1081
- const cfg = JSON.parse(readFileSync(file, "utf8"));
1081
+ const cfg = JSON.parse(readFileSync(source.file, "utf8"));
1082
1082
  return cfg?.acp?.enabled === false;
1083
1083
  }
1084
1084
  catch {
@@ -1491,12 +1491,13 @@ export async function probeOpenclawAgents(profile, opts = {}) {
1491
1491
  token: prepared.resolvedToken,
1492
1492
  timeoutMs: opts.timeoutMs ?? 3000,
1493
1493
  });
1494
- // For loopback gateways the agent roster lives in `~/.openclaw/openclaw.json`
1494
+ // For loopback gateways the agent roster lives in local OpenClaw config
1495
+ // (`~/.openclaw/openclaw.json`, or QClaw's `~/.qclaw/openclaw.json`)
1495
1496
  // and is the source of truth — listing it over the wire would require a
1496
1497
  // paired device identity (operator.read scope). When the WS probe is the
1497
1498
  // default (i.e. no test injection) we enrich the result from disk.
1498
1499
  if (result.ok && !result.agents && !opts.probe && isLoopbackUrl(profile.url)) {
1499
- const local = readLocalOpenclawAgents();
1500
+ const local = readLocalOpenclawAgents(profile.url);
1500
1501
  if (local && local.length > 0)
1501
1502
  result.agents = local;
1502
1503
  }
@@ -1511,17 +1512,18 @@ function isLoopbackUrl(raw) {
1511
1512
  return false;
1512
1513
  }
1513
1514
  }
1514
- function readLocalOpenclawAgents() {
1515
+ function readLocalOpenclawAgents(rawUrl) {
1515
1516
  try {
1516
- const file = path.join(homedir(), ".openclaw", "openclaw.json");
1517
- if (!existsSync(file))
1518
- return readLocalOpenclawAgentDirs() ?? [{ id: "default" }];
1517
+ const source = pickLocalOpenclawConfig(rawUrl);
1518
+ if (!source)
1519
+ return readLocalOpenclawAgentDirs(path.join(homedir(), ".openclaw")) ?? [{ id: "default" }];
1520
+ const { file, stateDir } = source;
1519
1521
  const cfg = JSON.parse(readFileSync(file, "utf8"));
1520
1522
  const list = Array.isArray(cfg?.agents?.list) ? cfg.agents.list : [];
1521
1523
  const explicitDefaultId = typeof cfg?.agents?.defaults?.id === "string" && cfg.agents.defaults.id
1522
1524
  ? cfg.agents.defaults.id
1523
1525
  : null;
1524
- const dirAgents = readLocalOpenclawAgentDirs();
1526
+ const dirAgents = readLocalOpenclawAgentDirs(stateDir);
1525
1527
  const defaultId = explicitDefaultId ?? (list.length === 0 && !dirAgents ? "default" : null);
1526
1528
  const seen = new Set();
1527
1529
  const out = [];
@@ -1565,16 +1567,52 @@ function readLocalOpenclawAgents() {
1565
1567
  return null;
1566
1568
  }
1567
1569
  }
1568
- function readLocalOpenclawAgentDirs() {
1570
+ function pickLocalOpenclawConfig(rawUrl) {
1571
+ const candidates = [
1572
+ { file: path.join(homedir(), ".openclaw", "openclaw.json"), stateDir: path.join(homedir(), ".openclaw") },
1573
+ { file: path.join(homedir(), ".qclaw", "openclaw.json"), stateDir: path.join(homedir(), ".qclaw") },
1574
+ ];
1575
+ const targetPort = urlPort(rawUrl);
1576
+ let firstExisting = null;
1577
+ for (const candidate of candidates) {
1578
+ if (!existsSync(candidate.file))
1579
+ continue;
1580
+ firstExisting ??= candidate;
1581
+ if (!targetPort)
1582
+ continue;
1583
+ try {
1584
+ const cfg = JSON.parse(readFileSync(candidate.file, "utf8"));
1585
+ if (Number(cfg?.gateway?.port) === targetPort)
1586
+ return candidate;
1587
+ }
1588
+ catch {
1589
+ // Try the next local config.
1590
+ }
1591
+ }
1592
+ return firstExisting;
1593
+ }
1594
+ function urlPort(rawUrl) {
1595
+ if (!rawUrl)
1596
+ return null;
1569
1597
  try {
1570
- const dir = path.join(homedir(), ".openclaw", "agents");
1598
+ const u = new URL(rawUrl);
1599
+ const port = Number(u.port || (u.protocol === "wss:" ? 443 : 80));
1600
+ return Number.isInteger(port) && port > 0 ? port : null;
1601
+ }
1602
+ catch {
1603
+ return null;
1604
+ }
1605
+ }
1606
+ function readLocalOpenclawAgentDirs(stateDir) {
1607
+ try {
1608
+ const dir = path.join(stateDir, "agents");
1571
1609
  if (!existsSync(dir))
1572
1610
  return null;
1573
1611
  const agents = readdirSync(dir, { withFileTypes: true })
1574
1612
  .filter((entry) => entry.isDirectory() && entry.name.length > 0)
1575
1613
  .map((entry) => ({
1576
1614
  id: entry.name,
1577
- workspace: path.join(dir, entry.name),
1615
+ workspace: resolveAgentDirWorkspace(dir, entry.name),
1578
1616
  }));
1579
1617
  if (agents.length === 0)
1580
1618
  return null;
@@ -1591,6 +1629,10 @@ function readLocalOpenclawAgentDirs() {
1591
1629
  return null;
1592
1630
  }
1593
1631
  }
1632
+ function resolveAgentDirWorkspace(agentsDir, agentId) {
1633
+ const nested = path.join(agentsDir, agentId, "agent");
1634
+ return existsSync(nested) ? nested : path.join(agentsDir, agentId);
1635
+ }
1594
1636
  function resolveOpenclawIdentityName(agentId, workspace, cfg) {
1595
1637
  const root = workspace ?? resolveOpenclawWorkspace(agentId, cfg);
1596
1638
  if (!root)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botcord/daemon",
3
- "version": "0.2.50",
3
+ "version": "0.2.52",
4
4
  "description": "BotCord local daemon — bridges Hub inbox push to local Claude Code / Codex / Gemini CLIs",
5
5
  "type": "module",
6
6
  "bin": {
@@ -128,6 +128,41 @@ describe("discoverLocalOpenclawGateways", () => {
128
128
  ]);
129
129
  });
130
130
 
131
+ it("discovers QClaw's state config and referenced OpenClaw config", async () => {
132
+ const dir = tempDir();
133
+ const openclawConfig = path.join(dir, "openclaw.json");
134
+ writeFileSync(
135
+ openclawConfig,
136
+ JSON.stringify({
137
+ gateway: {
138
+ port: 28789,
139
+ bind: "loopback",
140
+ auth: { mode: "token", token: "qclaw-token" },
141
+ },
142
+ }),
143
+ );
144
+ writeFileSync(
145
+ path.join(dir, "qclaw.json"),
146
+ JSON.stringify({
147
+ configPath: openclawConfig,
148
+ port: 28789,
149
+ }),
150
+ );
151
+
152
+ const found = await discoverLocalOpenclawGateways({
153
+ searchPaths: [dir],
154
+ defaultPorts: [],
155
+ });
156
+
157
+ expect(found).toEqual([
158
+ expect.objectContaining({
159
+ url: "ws://127.0.0.1:28789",
160
+ token: "qclaw-token",
161
+ source: "config-file",
162
+ }),
163
+ ]);
164
+ });
165
+
131
166
  it("uses OPENCLAW_ACP_URL and token env vars", async () => {
132
167
  const found = await discoverLocalOpenclawGateways({
133
168
  searchPaths: [],
@@ -269,8 +304,8 @@ describe("discoverLocalOpenclawGateways", () => {
269
304
  ]);
270
305
  });
271
306
 
272
- it("includes 16200 in default discovery ports", () => {
273
- expect(defaultOpenclawDiscoveryPorts()).toEqual(expect.arrayContaining([18789, 16200]));
307
+ it("includes OpenClaw and QClaw ports in default discovery ports", () => {
308
+ expect(defaultOpenclawDiscoveryPorts()).toEqual(expect.arrayContaining([18789, 16200, 28789]));
274
309
  });
275
310
 
276
311
  it("adds default-port candidates only when the probe succeeds", async () => {
@@ -27,6 +27,7 @@ vi.mock("../config.js", async () => {
27
27
  const {
28
28
  addAgentToConfig,
29
29
  adoptDiscoveredOpenclawAgents,
30
+ probeOpenclawAgents,
30
31
  removeAgentFromConfig,
31
32
  reloadConfig,
32
33
  setRoute,
@@ -34,6 +35,7 @@ const {
34
35
  } = await import("../provision.js");
35
36
  const { CONTROL_FRAME_TYPES } = await import("@botcord/protocol-core");
36
37
  import type { DaemonConfig } from "../config.js";
38
+ import type { WsEndpointProbeFn } from "../provision.js";
37
39
  import type {
38
40
  GatewayChannelConfig,
39
41
  GatewayRoute,
@@ -1139,7 +1141,7 @@ describe("adoptDiscoveredOpenclawAgents", () => {
1139
1141
  openclawGateways: [{ name: "local", url: "ws://127.0.0.1:18789" }],
1140
1142
  };
1141
1143
  const register = vi.fn();
1142
- const probe = vi.fn<Parameters<typeof adoptDiscoveredOpenclawAgents>[0]["probe"]>(
1144
+ const probe = vi.fn<WsEndpointProbeFn>(
1143
1145
  async () => ({ ok: true, agents: [{ id: "main" }] }),
1144
1146
  );
1145
1147
 
@@ -1224,6 +1226,70 @@ describe("adoptDiscoveredOpenclawAgents", () => {
1224
1226
  });
1225
1227
  });
1226
1228
 
1229
+ describe("probeOpenclawAgents local profiles", () => {
1230
+ it("enriches loopback QClaw gateways from ~/.qclaw/openclaw.json", async () => {
1231
+ await withSandboxHome(async ({ tmp, fs, path: nodePath }) => {
1232
+ const { WebSocketServer } = await import("ws");
1233
+ const qclawDir = nodePath.join(tmp, ".qclaw");
1234
+ fs.mkdirSync(qclawDir, { recursive: true });
1235
+
1236
+ const wss = new WebSocketServer({ host: "127.0.0.1", port: 0 });
1237
+ await new Promise<void>((resolve) => wss.once("listening", resolve));
1238
+ const address = wss.address();
1239
+ if (typeof address === "string" || address === null) {
1240
+ throw new Error("expected tcp websocket address");
1241
+ }
1242
+
1243
+ fs.writeFileSync(
1244
+ nodePath.join(qclawDir, "openclaw.json"),
1245
+ JSON.stringify({
1246
+ agents: {
1247
+ defaults: {
1248
+ workspace: nodePath.join(qclawDir, "workspace"),
1249
+ model: { primary: "qclaw/modelroute" },
1250
+ },
1251
+ list: [{ id: "main", name: "QClaw" }],
1252
+ },
1253
+ gateway: {
1254
+ port: address.port,
1255
+ auth: { mode: "token", token: "qclaw-token" },
1256
+ },
1257
+ }),
1258
+ );
1259
+
1260
+ wss.on("connection", (ws) => {
1261
+ ws.send(JSON.stringify({ type: "event", event: "connect.challenge", payload: { nonce: "n" } }));
1262
+ ws.on("message", (raw) => {
1263
+ const msg = JSON.parse(raw.toString("utf8"));
1264
+ if (msg.method === "connect") {
1265
+ ws.send(
1266
+ JSON.stringify({
1267
+ type: "res",
1268
+ id: msg.id,
1269
+ ok: true,
1270
+ payload: { type: "hello-ok", server: { version: "2026.4.21" } },
1271
+ }),
1272
+ );
1273
+ }
1274
+ });
1275
+ });
1276
+
1277
+ try {
1278
+ const res = await probeOpenclawAgents({
1279
+ url: `ws://127.0.0.1:${address.port}`,
1280
+ token: "qclaw-token",
1281
+ });
1282
+
1283
+ expect(res.ok).toBe(true);
1284
+ expect(res.version).toBe("2026.4.21");
1285
+ expect(res.agents).toEqual([{ id: "main", name: "QClaw" }]);
1286
+ } finally {
1287
+ await new Promise<void>((resolve) => wss.close(() => resolve()));
1288
+ }
1289
+ });
1290
+ });
1291
+ });
1292
+
1227
1293
  // ---------------------------------------------------------------------------
1228
1294
  // revoke_agent — new flag semantics (plan §11.3)
1229
1295
  // ---------------------------------------------------------------------------
@@ -618,6 +618,92 @@ describe("createBotCordChannel — streamBlock()", () => {
618
618
  }
619
619
  });
620
620
 
621
+ it("normalizes DeepSeek message.delta assistant text", async () => {
622
+ const fetchSpy = vi.fn().mockResolvedValue(new Response(null, { status: 204 }));
623
+ const realFetch = globalThis.fetch;
624
+ globalThis.fetch = fetchSpy as unknown as typeof fetch;
625
+ try {
626
+ const client = makeClient({
627
+ getHubUrl: vi.fn().mockReturnValue("https://hub.example.com"),
628
+ });
629
+ const channel = createBotCordChannel({
630
+ id: "botcord-main",
631
+ accountId: "ag_self",
632
+ agentId: "ag_self",
633
+ client,
634
+ hubBaseUrl: "https://hub.example.com",
635
+ });
636
+ await channel.streamBlock!({
637
+ traceId: "m_trace",
638
+ accountId: "ag_self",
639
+ conversationId: "rm_oc_1",
640
+ block: {
641
+ kind: "assistant_text",
642
+ seq: 4,
643
+ raw: {
644
+ event: "message.delta",
645
+ payload: { thread_id: "thr_1", turn_id: "turn_1", content: "hello " },
646
+ },
647
+ },
648
+ log: silentLog,
649
+ });
650
+ const [, init] = fetchSpy.mock.calls[0];
651
+ const body = JSON.parse(init.body as string);
652
+ expect(body.block).toEqual({
653
+ kind: "assistant",
654
+ seq: 4,
655
+ payload: { text: "hello " },
656
+ });
657
+ } finally {
658
+ globalThis.fetch = realFetch;
659
+ }
660
+ });
661
+
662
+ it("normalizes DeepSeek item.delta assistant text", async () => {
663
+ const fetchSpy = vi.fn().mockResolvedValue(new Response(null, { status: 204 }));
664
+ const realFetch = globalThis.fetch;
665
+ globalThis.fetch = fetchSpy as unknown as typeof fetch;
666
+ try {
667
+ const client = makeClient({
668
+ getHubUrl: vi.fn().mockReturnValue("https://hub.example.com"),
669
+ });
670
+ const channel = createBotCordChannel({
671
+ id: "botcord-main",
672
+ accountId: "ag_self",
673
+ agentId: "ag_self",
674
+ client,
675
+ hubBaseUrl: "https://hub.example.com",
676
+ });
677
+ await channel.streamBlock!({
678
+ traceId: "m_trace",
679
+ accountId: "ag_self",
680
+ conversationId: "rm_oc_1",
681
+ block: {
682
+ kind: "assistant_text",
683
+ seq: 5,
684
+ raw: {
685
+ event: "item.delta",
686
+ payload: {
687
+ thread_id: "thr_1",
688
+ turn_id: "turn_1",
689
+ payload: { kind: "agent_message", delta: "deepseek" },
690
+ },
691
+ },
692
+ },
693
+ log: silentLog,
694
+ });
695
+ const [, init] = fetchSpy.mock.calls[0];
696
+ const body = JSON.parse(init.body as string);
697
+ expect(body.block).toEqual({
698
+ kind: "assistant",
699
+ seq: 5,
700
+ payload: { text: "deepseek" },
701
+ });
702
+ } finally {
703
+ globalThis.fetch = realFetch;
704
+ }
705
+ });
706
+
621
707
  it("normalizes a thinking block with phase/label/source payload", async () => {
622
708
  const fetchSpy = vi.fn().mockResolvedValue(new Response(null, { status: 204 }));
623
709
  const realFetch = globalThis.fetch;
@@ -18,7 +18,7 @@ afterAll(() => {
18
18
  rmSync(tmpRoot, { recursive: true, force: true });
19
19
  });
20
20
 
21
- function runAdapter(script: string, sessionId: string | null = null) {
21
+ function runAdapter(script: string, sessionId: string | null = null, extraArgs?: string[]) {
22
22
  const adapter = new KimiAdapter({ binary: script });
23
23
  const ctrl = new AbortController();
24
24
  return adapter.run({
@@ -28,6 +28,7 @@ function runAdapter(script: string, sessionId: string | null = null) {
28
28
  cwd: tmpRoot,
29
29
  signal: ctrl.signal,
30
30
  trustLevel: "owner",
31
+ extraArgs,
31
32
  });
32
33
  }
33
34
 
@@ -69,6 +70,89 @@ process.stdout.write(JSON.stringify({role:"assistant", content:JSON.stringify(ar
69
70
  expect(argv).toContain("--afk");
70
71
  });
71
72
 
73
+ it("drops non-Kimi inherited extraArgs and their values", async () => {
74
+ const script = makeScript(
75
+ "filter-foreign-argv.js",
76
+ `
77
+ const argv = process.argv.slice(2);
78
+ process.stdout.write(JSON.stringify({role:"assistant", content:JSON.stringify(argv)}) + "\\n");
79
+ `,
80
+ );
81
+ const res = await runAdapter(script, "sid-123", [
82
+ "--permission-mode",
83
+ "bypassPermissions",
84
+ "--model",
85
+ "kimi-k2",
86
+ ]);
87
+ const argv = JSON.parse(res.text) as string[];
88
+ expect(argv).not.toContain("--permission-mode");
89
+ expect(argv).not.toContain("bypassPermissions");
90
+ expect(argv).toContain("--model");
91
+ expect(argv[argv.indexOf("--model") + 1]).toBe("kimi-k2");
92
+ });
93
+
94
+ it("preserves Kimi value flags with negative numeric values", async () => {
95
+ const script = makeScript(
96
+ "negative-value-argv.js",
97
+ `
98
+ const argv = process.argv.slice(2);
99
+ process.stdout.write(JSON.stringify({role:"assistant", content:JSON.stringify(argv)}) + "\\n");
100
+ `,
101
+ );
102
+ const res = await runAdapter(script, "sid-123", [
103
+ "--max-ralph-iterations",
104
+ "-1",
105
+ "--max-steps-per-turn=3",
106
+ ]);
107
+ const argv = JSON.parse(res.text) as string[];
108
+ expect(argv).toContain("--max-ralph-iterations");
109
+ expect(argv[argv.indexOf("--max-ralph-iterations") + 1]).toBe("-1");
110
+ expect(argv).toContain("--max-steps-per-turn=3");
111
+ });
112
+
113
+ it("drops incomplete Kimi value flags instead of passing invalid argv", async () => {
114
+ const script = makeScript(
115
+ "incomplete-value-argv.js",
116
+ `
117
+ const argv = process.argv.slice(2);
118
+ process.stdout.write(JSON.stringify({role:"assistant", content:JSON.stringify(argv)}) + "\\n");
119
+ `,
120
+ );
121
+ const res = await runAdapter(script, "sid-123", ["--model", "--plan"]);
122
+ const argv = JSON.parse(res.text) as string[];
123
+ expect(argv).not.toContain("--model");
124
+ expect(argv).toContain("--plan");
125
+ });
126
+
127
+ it("does not let extraArgs override adapter-owned stream/session/prompt flags", async () => {
128
+ const script = makeScript(
129
+ "filter-owned-argv.js",
130
+ `
131
+ const argv = process.argv.slice(2);
132
+ process.stdout.write(JSON.stringify({role:"assistant", content:JSON.stringify(argv)}) + "\\n");
133
+ `,
134
+ );
135
+ const res = await runAdapter(script, "real-session", [
136
+ "--output-format",
137
+ "text",
138
+ "--session",
139
+ "evil-session",
140
+ "--prompt",
141
+ "evil prompt",
142
+ "--plan",
143
+ ]);
144
+ const argv = JSON.parse(res.text) as string[];
145
+ expect(argv.filter((a) => a === "--output-format")).toHaveLength(1);
146
+ expect(argv[argv.indexOf("--output-format") + 1]).toBe("stream-json");
147
+ expect(argv.filter((a) => a === "--session")).toHaveLength(1);
148
+ expect(argv[argv.indexOf("--session") + 1]).toBe("real-session");
149
+ expect(argv.filter((a) => a === "--prompt")).toHaveLength(1);
150
+ expect(argv[argv.indexOf("--prompt") + 1]).toBe("hi");
151
+ expect(argv).toContain("--plan");
152
+ expect(argv).not.toContain("evil-session");
153
+ expect(argv).not.toContain("evil prompt");
154
+ });
155
+
72
156
  it("rejects session ids that could be parsed as flags", async () => {
73
157
  const script = makeScript(
74
158
  "should-not-spawn.js",
@@ -907,12 +907,25 @@ function normalizeBlockForHub(
907
907
  if (kind === "assistant_text") {
908
908
  // Claude Code: {type:"assistant", message:{content:[{type:"text",text}]}}
909
909
  // Codex: {type:"item.completed", item:{type:"agent_message", text}}
910
+ // DeepSeek: {event:"message.delta", payload:{content}} or
911
+ // {event:"item.delta", payload:{payload:{kind:"agent_message", delta}}}
910
912
  let text = "";
911
913
  const contents = Array.isArray(raw?.message?.content) ? raw.message.content : [];
912
914
  for (const c of contents) {
913
915
  if (c?.type === "text" && typeof c.text === "string") text += c.text;
914
916
  }
915
917
  if (!text && typeof raw?.item?.text === "string") text = raw.item.text;
918
+ if (!text && raw?.event === "message.delta" && typeof raw?.payload?.content === "string") {
919
+ text = raw.payload.content;
920
+ }
921
+ if (
922
+ !text &&
923
+ raw?.event === "item.delta" &&
924
+ raw?.payload?.payload?.kind === "agent_message" &&
925
+ typeof raw?.payload?.payload?.delta === "string"
926
+ ) {
927
+ text = raw.payload.payload.delta;
928
+ }
916
929
  return { kind: "assistant", seq, payload: { text } };
917
930
  }
918
931
 
@@ -22,6 +22,113 @@ function invalidKimiSessionIdError(): string {
22
22
  return "kimi-cli: invalid sessionId (expected non-control text not starting with '-')";
23
23
  }
24
24
 
25
+ const KIMI_EXTRA_FLAGS_WITH_VALUE = new Set([
26
+ "--add-dir",
27
+ "--agent",
28
+ "--agent-file",
29
+ "--config",
30
+ "--config-file",
31
+ "--max-ralph-iterations",
32
+ "--max-retries-per-step",
33
+ "--max-steps-per-turn",
34
+ "--mcp-config",
35
+ "--mcp-config-file",
36
+ "--model",
37
+ "--skills-dir",
38
+ "-m",
39
+ ]);
40
+
41
+ const KIMI_EXTRA_BOOLEAN_FLAGS = new Set([
42
+ "--afk",
43
+ "--auto-approve",
44
+ "--debug",
45
+ "--no-thinking",
46
+ "--plan",
47
+ "--thinking",
48
+ "--verbose",
49
+ "--yes",
50
+ "--yolo",
51
+ "-y",
52
+ ]);
53
+
54
+ // Flags owned by the adapter because BotCord depends on Kimi's non-interactive
55
+ // stream-json contract, cwd isolation, prompt placement, and session routing.
56
+ const KIMI_ADAPTER_OWNED_FLAGS = new Set([
57
+ "--acp",
58
+ "--command",
59
+ "--continue",
60
+ "--final-message-only",
61
+ "--help",
62
+ "--input-format",
63
+ "--output-format",
64
+ "--print",
65
+ "--prompt",
66
+ "--quiet",
67
+ "--resume",
68
+ "--session",
69
+ "--version",
70
+ "--wire",
71
+ "--work-dir",
72
+ "-C",
73
+ "-S",
74
+ "-V",
75
+ "-c",
76
+ "-h",
77
+ "-p",
78
+ "-r",
79
+ "-w",
80
+ ]);
81
+
82
+ function flagName(arg: string): string {
83
+ if (!arg.startsWith("-")) return arg;
84
+ const eq = arg.indexOf("=");
85
+ return eq === -1 ? arg : arg.slice(0, eq);
86
+ }
87
+
88
+ function nextValue(args: string[], index: number): string | undefined {
89
+ const next = args[index + 1];
90
+ if (typeof next !== "string") return undefined;
91
+ if (!next.startsWith("-")) return next;
92
+ return /^-\d/.test(next) ? next : undefined;
93
+ }
94
+
95
+ function sanitizeKimiExtraArgs(extraArgs: string[] | undefined): string[] {
96
+ if (!extraArgs?.length) return [];
97
+ const out: string[] = [];
98
+ for (let i = 0; i < extraArgs.length; i += 1) {
99
+ const arg = extraArgs[i];
100
+ const name = flagName(arg);
101
+
102
+ if (KIMI_ADAPTER_OWNED_FLAGS.has(name)) {
103
+ if (!arg.includes("=") && nextValue(extraArgs, i) !== undefined) i += 1;
104
+ continue;
105
+ }
106
+
107
+ if (KIMI_EXTRA_FLAGS_WITH_VALUE.has(name)) {
108
+ if (arg.includes("=")) {
109
+ out.push(arg);
110
+ continue;
111
+ }
112
+ const value = nextValue(extraArgs, i);
113
+ if (value !== undefined) {
114
+ out.push(arg, value);
115
+ i += 1;
116
+ }
117
+ continue;
118
+ }
119
+
120
+ if (KIMI_EXTRA_BOOLEAN_FLAGS.has(name)) {
121
+ out.push(arg);
122
+ continue;
123
+ }
124
+
125
+ if (arg.startsWith("-") && !arg.includes("=") && nextValue(extraArgs, i) !== undefined) {
126
+ i += 1;
127
+ }
128
+ }
129
+ return out;
130
+ }
131
+
25
132
  /** Resolve the Kimi CLI executable on PATH. */
26
133
  export function resolveKimiCommand(deps: ProbeDeps = {}): string | null {
27
134
  return resolveCommandOnPath("kimi", deps);
@@ -41,7 +148,7 @@ export function probeKimi(deps: ProbeDeps = {}): RuntimeProbeResult {
41
148
  /**
42
149
  * Kimi CLI adapter — spawns:
43
150
  *
44
- * kimi --work-dir <cwd> --print --output-format stream-json --session <sid> --prompt <text>
151
+ * kimi --work-dir <cwd> --print --output-format stream-json --session <sid> --afk --prompt <text>
45
152
  *
46
153
  * `--session <sid>` resumes an existing session or creates a new session with
47
154
  * that id, so the adapter generates a UUID on first turn and persists it for
@@ -93,7 +200,7 @@ export class KimiAdapter extends NdjsonStreamAdapter {
93
200
  sessionId,
94
201
  "--afk",
95
202
  ];
96
- if (opts.extraArgs?.length) args.push(...opts.extraArgs);
203
+ args.push(...sanitizeKimiExtraArgs(opts.extraArgs));
97
204
  args.push("--prompt", promptWithSystemContext(opts.text, opts.systemContext));
98
205
  return args;
99
206
  }
@@ -34,8 +34,8 @@ export interface MergeOpenclawGatewayResult {
34
34
  added: OpenclawGatewayProfile[];
35
35
  }
36
36
 
37
- const DEFAULT_SEARCH_PATHS = ["~/.openclaw/", "/etc/openclaw/"];
38
- const DEFAULT_PORTS = [18789, 16200];
37
+ const DEFAULT_SEARCH_PATHS = ["~/.openclaw/", "~/.qclaw/", "/etc/openclaw/"];
38
+ const DEFAULT_PORTS = [18789, 16200, 28789];
39
39
  const DEFAULT_TOKEN_FILE_PATHS = [
40
40
  "/run/openclaw/gateway-token",
41
41
  "/var/run/openclaw/gateway-token",
@@ -382,6 +382,8 @@ function discoverFromConfigDir(root: string): DiscoveredOpenclawGateway[] {
382
382
 
383
383
  function parseJsonConfig(raw: string): { url?: string; token?: string; tokenFile?: string } | null {
384
384
  const obj = JSON.parse(raw) as any;
385
+ const qclaw = pickQclawGatewayValues(obj);
386
+ if (qclaw) return qclaw;
385
387
  // Prefer OpenClaw's native shape: `gateway.port` + `gateway.auth.token`.
386
388
  // The legacy `acp.url` shape is also supported for explicit user-authored configs.
387
389
  const native = pickOpenclawGatewayValues(obj?.gateway);
@@ -390,6 +392,35 @@ function parseJsonConfig(raw: string): { url?: string; token?: string; tokenFile
390
392
  return pickConfigValues(acp);
391
393
  }
392
394
 
395
+ function pickQclawGatewayValues(
396
+ obj: any,
397
+ ): { url?: string; token?: string; tokenFile?: string } | null {
398
+ if (!obj || typeof obj !== "object") return null;
399
+ const port = typeof obj.port === "number" ? obj.port : undefined;
400
+ const configPath = typeof obj.configPath === "string" && obj.configPath.trim()
401
+ ? obj.configPath.trim()
402
+ : undefined;
403
+ if (!port && !configPath) return null;
404
+
405
+ const fromConfig = configPath ? readGatewayValuesFromConfigPath(configPath) : null;
406
+ if (fromConfig) return fromConfig;
407
+ if (!port) return null;
408
+ return { url: `ws://127.0.0.1:${port}` };
409
+ }
410
+
411
+ function readGatewayValuesFromConfigPath(
412
+ configPath: string,
413
+ ): { url?: string; token?: string; tokenFile?: string } | null {
414
+ try {
415
+ const raw = readFileSync(expandHome(configPath), "utf8");
416
+ const parsed = parseJsonConfig(raw);
417
+ if (parsed?.url) return parsed;
418
+ } catch {
419
+ // qclaw.json may be copied without its referenced openclaw.json.
420
+ }
421
+ return null;
422
+ }
423
+
393
424
  function pickOpenclawGatewayValues(
394
425
  gw: any,
395
426
  ): { url?: string; token?: string; tokenFile?: string } | null {
package/src/provision.ts CHANGED
@@ -1362,9 +1362,9 @@ export async function adoptDiscoveredOpenclawAgents(ctx: {
1362
1362
  function localOpenclawAcpDisabled(rawUrl: string): boolean {
1363
1363
  if (!isLoopbackUrl(rawUrl)) return false;
1364
1364
  try {
1365
- const file = path.join(homedir(), ".openclaw", "openclaw.json");
1366
- if (!existsSync(file)) return false;
1367
- const cfg = JSON.parse(readFileSync(file, "utf8")) as any;
1365
+ const source = pickLocalOpenclawConfig(rawUrl);
1366
+ if (!source) return false;
1367
+ const cfg = JSON.parse(readFileSync(source.file, "utf8")) as any;
1368
1368
  return cfg?.acp?.enabled === false;
1369
1369
  } catch {
1370
1370
  return false;
@@ -1838,12 +1838,13 @@ export async function probeOpenclawAgents(
1838
1838
  token: prepared.resolvedToken,
1839
1839
  timeoutMs: opts.timeoutMs ?? 3000,
1840
1840
  });
1841
- // For loopback gateways the agent roster lives in `~/.openclaw/openclaw.json`
1841
+ // For loopback gateways the agent roster lives in local OpenClaw config
1842
+ // (`~/.openclaw/openclaw.json`, or QClaw's `~/.qclaw/openclaw.json`)
1842
1843
  // and is the source of truth — listing it over the wire would require a
1843
1844
  // paired device identity (operator.read scope). When the WS probe is the
1844
1845
  // default (i.e. no test injection) we enrich the result from disk.
1845
1846
  if (result.ok && !result.agents && !opts.probe && isLoopbackUrl(profile.url)) {
1846
- const local = readLocalOpenclawAgents();
1847
+ const local = readLocalOpenclawAgents(profile.url);
1847
1848
  if (local && local.length > 0) result.agents = local;
1848
1849
  }
1849
1850
  return result;
@@ -1858,22 +1859,23 @@ function isLoopbackUrl(raw: string): boolean {
1858
1859
  }
1859
1860
  }
1860
1861
 
1861
- function readLocalOpenclawAgents(): Array<{
1862
+ function readLocalOpenclawAgents(rawUrl?: string): Array<{
1862
1863
  id: string;
1863
1864
  name?: string;
1864
1865
  workspace?: string;
1865
1866
  model?: { name?: string; provider?: string };
1866
1867
  }> | null {
1867
1868
  try {
1868
- const file = path.join(homedir(), ".openclaw", "openclaw.json");
1869
- if (!existsSync(file)) return readLocalOpenclawAgentDirs() ?? [{ id: "default" }];
1869
+ const source = pickLocalOpenclawConfig(rawUrl);
1870
+ if (!source) return readLocalOpenclawAgentDirs(path.join(homedir(), ".openclaw")) ?? [{ id: "default" }];
1871
+ const { file, stateDir } = source;
1870
1872
  const cfg = JSON.parse(readFileSync(file, "utf8")) as any;
1871
1873
  const list = Array.isArray(cfg?.agents?.list) ? cfg.agents.list : [];
1872
1874
  const explicitDefaultId =
1873
1875
  typeof cfg?.agents?.defaults?.id === "string" && cfg.agents.defaults.id
1874
1876
  ? cfg.agents.defaults.id
1875
1877
  : null;
1876
- const dirAgents = readLocalOpenclawAgentDirs();
1878
+ const dirAgents = readLocalOpenclawAgentDirs(stateDir);
1877
1879
  const defaultId = explicitDefaultId ?? (list.length === 0 && !dirAgents ? "default" : null);
1878
1880
  const seen = new Set<string>();
1879
1881
  const out: Array<{ id: string; name?: string; workspace?: string; model?: { name?: string; provider?: string } }> = [];
@@ -1906,18 +1908,50 @@ function readLocalOpenclawAgents(): Array<{
1906
1908
  }
1907
1909
  }
1908
1910
 
1909
- function readLocalOpenclawAgentDirs(): Array<{
1911
+ function pickLocalOpenclawConfig(rawUrl?: string): { file: string; stateDir: string } | null {
1912
+ const candidates = [
1913
+ { file: path.join(homedir(), ".openclaw", "openclaw.json"), stateDir: path.join(homedir(), ".openclaw") },
1914
+ { file: path.join(homedir(), ".qclaw", "openclaw.json"), stateDir: path.join(homedir(), ".qclaw") },
1915
+ ];
1916
+ const targetPort = urlPort(rawUrl);
1917
+ let firstExisting: { file: string; stateDir: string } | null = null;
1918
+ for (const candidate of candidates) {
1919
+ if (!existsSync(candidate.file)) continue;
1920
+ firstExisting ??= candidate;
1921
+ if (!targetPort) continue;
1922
+ try {
1923
+ const cfg = JSON.parse(readFileSync(candidate.file, "utf8")) as any;
1924
+ if (Number(cfg?.gateway?.port) === targetPort) return candidate;
1925
+ } catch {
1926
+ // Try the next local config.
1927
+ }
1928
+ }
1929
+ return firstExisting;
1930
+ }
1931
+
1932
+ function urlPort(rawUrl?: string): number | null {
1933
+ if (!rawUrl) return null;
1934
+ try {
1935
+ const u = new URL(rawUrl);
1936
+ const port = Number(u.port || (u.protocol === "wss:" ? 443 : 80));
1937
+ return Number.isInteger(port) && port > 0 ? port : null;
1938
+ } catch {
1939
+ return null;
1940
+ }
1941
+ }
1942
+
1943
+ function readLocalOpenclawAgentDirs(stateDir: string): Array<{
1910
1944
  id: string;
1911
1945
  workspace?: string;
1912
1946
  }> | null {
1913
1947
  try {
1914
- const dir = path.join(homedir(), ".openclaw", "agents");
1948
+ const dir = path.join(stateDir, "agents");
1915
1949
  if (!existsSync(dir)) return null;
1916
1950
  const agents = readdirSync(dir, { withFileTypes: true })
1917
1951
  .filter((entry) => entry.isDirectory() && entry.name.length > 0)
1918
1952
  .map((entry) => ({
1919
1953
  id: entry.name,
1920
- workspace: path.join(dir, entry.name),
1954
+ workspace: resolveAgentDirWorkspace(dir, entry.name),
1921
1955
  }));
1922
1956
  if (agents.length === 0) return null;
1923
1957
  agents.sort((a, b) => {
@@ -1931,6 +1965,11 @@ function readLocalOpenclawAgentDirs(): Array<{
1931
1965
  }
1932
1966
  }
1933
1967
 
1968
+ function resolveAgentDirWorkspace(agentsDir: string, agentId: string): string {
1969
+ const nested = path.join(agentsDir, agentId, "agent");
1970
+ return existsSync(nested) ? nested : path.join(agentsDir, agentId);
1971
+ }
1972
+
1934
1973
  function resolveOpenclawIdentityName(
1935
1974
  agentId: string,
1936
1975
  workspace?: string,