@botcord/daemon 0.2.51 → 0.2.53

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.
@@ -10,10 +10,13 @@
10
10
  export type FetchLike = (input: string, init?: {
11
11
  method?: string;
12
12
  headers?: Record<string, string>;
13
- body?: string;
13
+ body?: BodyInit | Uint8Array | string;
14
14
  signal?: AbortSignal;
15
15
  }) => Promise<{
16
16
  status?: number;
17
17
  ok?: boolean;
18
+ headers?: {
19
+ get(name: string): string | null;
20
+ };
18
21
  text(): Promise<string>;
19
22
  }>;
@@ -1,10 +1,13 @@
1
+ import { basename } from "node:path";
2
+ import { readFile } from "node:fs/promises";
3
+ import { createCipheriv, createHash, randomBytes, randomUUID, } from "node:crypto";
1
4
  import { sanitizeUntrustedContent } from "./sanitize.js";
2
5
  import { GatewayStateStore } from "./state-store.js";
3
6
  import { loadGatewaySecret } from "./secret-store.js";
4
7
  import { splitText } from "./text-split.js";
5
8
  import { wechatHeaders, WECHAT_BASE_INFO } from "./wechat-http.js";
6
- import { randomUUID } from "node:crypto";
7
9
  const DEFAULT_BASE_URL = "https://ilinkai.weixin.qq.com";
10
+ const DEFAULT_CDN_BASE_URL = "https://novac2c.cdn.weixin.qq.com/c2c";
8
11
  /**
9
12
  * Replace every occurrence of `token` in `input` with `"[REDACTED]"`.
10
13
  * No-ops when token is falsy (not yet loaded).
@@ -149,6 +152,125 @@ export function createWechatChannel(opts) {
149
152
  return {};
150
153
  }
151
154
  }
155
+ function cdnUploadUrl(resp) {
156
+ if (typeof resp.upload_full_url === "string" && resp.upload_full_url.length > 0) {
157
+ return resp.upload_full_url;
158
+ }
159
+ if (typeof resp.upload_param === "string" && resp.upload_param.length > 0) {
160
+ return `${DEFAULT_CDN_BASE_URL}/upload?encrypted_query_param=${encodeURIComponent(resp.upload_param)}`;
161
+ }
162
+ return null;
163
+ }
164
+ async function uploadEncryptedMedia(trace, attachment) {
165
+ const raw = attachment.data ??
166
+ (attachment.filePath ? await readFile(attachment.filePath) : undefined);
167
+ if (!raw || raw.length === 0) {
168
+ throw new Error("wechat media upload requires non-empty attachment data or filePath");
169
+ }
170
+ const data = Buffer.from(raw);
171
+ const filename = attachment.filename ??
172
+ (attachment.filePath ? basename(attachment.filePath) : "attachment");
173
+ const kind = attachment.kind ?? kindFromContentType(attachment.contentType);
174
+ const mediaType = kind === "image" ? 1 : kind === "video" ? 2 : 3;
175
+ const itemType = kind === "image" ? 2 : kind === "video" ? 5 : 4;
176
+ const aesKey = randomBytes(16);
177
+ const aesKeyHex = aesKey.toString("hex");
178
+ const encrypted = encryptAes128Ecb(data, aesKey);
179
+ const filekey = `botcord-${randomUUID()}`;
180
+ const uploadResp = await callApi("ilink/bot/getuploadurl", {
181
+ filekey,
182
+ media_type: mediaType,
183
+ to_user_id: trace.fromUserId,
184
+ rawsize: data.length,
185
+ rawfilemd5: md5Hex(data),
186
+ filesize: encrypted.length,
187
+ aeskey: aesKeyHex,
188
+ no_need_thumb: true,
189
+ }, 15_000);
190
+ if (uploadResp.ret !== 0 && uploadResp.ret !== undefined) {
191
+ throw new Error(redactSecret(`wechat getuploadurl failed: ret=${uploadResp.ret}`, botToken));
192
+ }
193
+ const uploadUrl = cdnUploadUrl(uploadResp);
194
+ if (!uploadUrl)
195
+ throw new Error("wechat getuploadurl returned no upload URL");
196
+ const uploadResult = await fetchImpl(uploadUrl, {
197
+ method: "POST",
198
+ headers: { "Content-Type": "application/octet-stream" },
199
+ body: encrypted,
200
+ signal: AbortSignal.timeout(30_000),
201
+ });
202
+ const encryptedParam = uploadResult.headers?.get("x-encrypted-param") ??
203
+ uploadResult.headers?.get("X-Encrypted-Param") ??
204
+ (await readEncryptedParamFromBody(uploadResult));
205
+ if (!encryptedParam) {
206
+ throw new Error("wechat CDN upload returned no x-encrypted-param");
207
+ }
208
+ const media = {
209
+ encrypt_query_param: encryptedParam,
210
+ aes_key: Buffer.from(aesKeyHex, "utf8").toString("base64"),
211
+ };
212
+ if (itemType === 2) {
213
+ return {
214
+ type: itemType,
215
+ image_item: {
216
+ media,
217
+ aeskey: aesKeyHex,
218
+ mid_size: data.length,
219
+ },
220
+ };
221
+ }
222
+ if (itemType === 5) {
223
+ return {
224
+ type: itemType,
225
+ video_item: {
226
+ media,
227
+ video_size: data.length,
228
+ file_name: filename,
229
+ },
230
+ };
231
+ }
232
+ return {
233
+ type: itemType,
234
+ file_item: {
235
+ media,
236
+ file_name: filename,
237
+ md5: md5Hex(data),
238
+ len: data.length,
239
+ },
240
+ };
241
+ }
242
+ async function readEncryptedParamFromBody(resp) {
243
+ const raw = await resp.text().catch(() => "");
244
+ if (!raw)
245
+ return null;
246
+ try {
247
+ const json = JSON.parse(raw);
248
+ const v = json.encrypted_query_param ?? json.encrypt_query_param ?? json.upload_param;
249
+ return typeof v === "string" && v.length > 0 ? v : null;
250
+ }
251
+ catch {
252
+ return null;
253
+ }
254
+ }
255
+ async function sendItems(trace, items) {
256
+ const clientId = `botcord-${randomUUID()}`;
257
+ const body = {
258
+ msg: {
259
+ from_user_id: "",
260
+ to_user_id: trace.fromUserId,
261
+ client_id: clientId,
262
+ message_type: 2, // BOT → user
263
+ message_state: 2, // FINISH
264
+ context_token: trace.contextToken,
265
+ item_list: items,
266
+ },
267
+ };
268
+ const resp = await callApi("ilink/bot/sendmessage", body, 15_000);
269
+ if (resp.ret !== 0 && resp.ret !== undefined) {
270
+ throw new Error(redactSecret(`wechat sendmessage failed: ret=${resp.ret}`, botToken));
271
+ }
272
+ return clientId;
273
+ }
152
274
  function extractText(msg) {
153
275
  const parts = [];
154
276
  for (const item of msg.item_list ?? []) {
@@ -398,27 +520,23 @@ export function createWechatChannel(opts) {
398
520
  throw new Error(`wechat send: no context_token for traceId=${message.traceId ?? "<missing>"}` +
399
521
  ` (expired or never bound — daemon does not support unsolicited replies)`);
400
522
  }
401
- const chunks = splitText(message.text, splitAt);
523
+ const chunks = message.text.length > 0 ? splitText(message.text, splitAt) : [];
402
524
  let lastClientId = null;
403
525
  for (const chunk of chunks) {
404
- const clientId = `botcord-${randomUUID()}`;
405
- const body = {
406
- msg: {
407
- from_user_id: "",
408
- to_user_id: trace.fromUserId,
409
- client_id: clientId,
410
- message_type: 2, // BOT → user
411
- message_state: 2, // FINISH
412
- context_token: trace.contextToken,
413
- item_list: [{ type: 1, text_item: { text: chunk } }],
414
- },
415
- };
416
- const resp = await callApi("ilink/bot/sendmessage", body, 15_000);
417
- if (resp.ret !== 0 && resp.ret !== undefined) {
418
- log.warn("wechat sendmessage non-zero ret", { ret: resp.ret });
419
- throw new Error(redactSecret(`wechat sendmessage failed: ret=${resp.ret}`, botToken));
526
+ lastClientId = await sendItems(trace, [{ type: 1, text_item: { text: chunk } }]);
527
+ }
528
+ for (const attachment of message.attachments ?? []) {
529
+ try {
530
+ const item = await uploadEncryptedMedia(trace, attachment);
531
+ lastClientId = await sendItems(trace, [item]);
532
+ }
533
+ catch (err) {
534
+ log.warn("wechat media send failed", {
535
+ err: redactSecret(String(err), botToken),
536
+ filename: attachment.filename ?? attachment.filePath ?? "attachment",
537
+ });
538
+ throw err;
420
539
  }
421
- lastClientId = clientId;
422
540
  }
423
541
  const sendAt = Date.now();
424
542
  statusSnapshot = { ...statusSnapshot, lastSendAt: sendAt };
@@ -453,6 +571,21 @@ export function createWechatChannel(opts) {
453
571
  };
454
572
  return adapter;
455
573
  }
574
+ function md5Hex(data) {
575
+ return createHash("md5").update(data).digest("hex");
576
+ }
577
+ function encryptAes128Ecb(data, key) {
578
+ const cipher = createCipheriv("aes-128-ecb", key, null);
579
+ cipher.setAutoPadding(true);
580
+ return Buffer.concat([cipher.update(data), cipher.final()]);
581
+ }
582
+ function kindFromContentType(contentType) {
583
+ if (contentType?.startsWith("image/"))
584
+ return "image";
585
+ if (contentType?.startsWith("video/"))
586
+ return "video";
587
+ return "file";
588
+ }
456
589
  function sleep(ms, signal) {
457
590
  return new Promise((resolve) => {
458
591
  if (signal?.aborted) {
@@ -140,12 +140,22 @@ export type UserTurnBuilder = (message: GatewayInboundMessage) => string;
140
140
  */
141
141
  export type OutboundObserver = (message: GatewayOutboundMessage) => Promise<void> | void;
142
142
  /** Outbound reply payload passed to `ChannelAdapter.send()`. */
143
+ export interface GatewayOutboundAttachment {
144
+ /** Local daemon-readable file path. */
145
+ filePath?: string;
146
+ /** In-memory bytes, primarily for tests and in-process tool callers. */
147
+ data?: Uint8Array;
148
+ filename?: string;
149
+ contentType?: string;
150
+ kind?: "image" | "file" | "video";
151
+ }
143
152
  export interface GatewayOutboundMessage {
144
153
  channel: string;
145
154
  accountId: string;
146
155
  conversationId: string;
147
156
  threadId?: string | null;
148
157
  text: string;
158
+ attachments?: GatewayOutboundAttachment[];
149
159
  replyTo?: string | null;
150
160
  traceId?: string | null;
151
161
  }
package/dist/index.js CHANGED
@@ -172,6 +172,48 @@ function pidAlive(pid) {
172
172
  return false;
173
173
  }
174
174
  }
175
+ async function waitForPidExit(pid, timeoutMs) {
176
+ const deadline = Date.now() + timeoutMs;
177
+ while (Date.now() < deadline) {
178
+ if (!pidAlive(pid))
179
+ return true;
180
+ await delay(100);
181
+ }
182
+ return !pidAlive(pid);
183
+ }
184
+ async function stopExistingDaemonForRestart(pid) {
185
+ if (pid === process.pid)
186
+ return;
187
+ log.info("existing daemon found; restarting", { pid });
188
+ try {
189
+ process.kill(pid, "SIGTERM");
190
+ }
191
+ catch {
192
+ try {
193
+ unlinkSync(PID_PATH);
194
+ }
195
+ catch {
196
+ // ignore
197
+ }
198
+ return;
199
+ }
200
+ if (!(await waitForPidExit(pid, 5_000))) {
201
+ log.warn("existing daemon did not stop after SIGTERM; sending SIGKILL", { pid });
202
+ try {
203
+ process.kill(pid, "SIGKILL");
204
+ }
205
+ catch {
206
+ // ignore
207
+ }
208
+ await waitForPidExit(pid, 2_000);
209
+ }
210
+ try {
211
+ unlinkSync(PID_PATH);
212
+ }
213
+ catch {
214
+ // ignore
215
+ }
216
+ }
175
217
  /**
176
218
  * Load the daemon config, auto-creating `~/.botcord/daemon/config.json`
177
219
  * with sensible defaults on first run. `--agent` (repeated) pins explicit
@@ -251,6 +293,8 @@ async function redeemInstallToken(opts) {
251
293
  const body = { install_token: opts.installToken };
252
294
  if (opts.label)
253
295
  body.label = opts.label;
296
+ if (opts.daemonInstanceId)
297
+ body.daemon_instance_id = opts.daemonInstanceId;
254
298
  const resp = await fetch(`${opts.hubUrl.replace(/\/+$/, "")}/daemon/auth/install-token`, {
255
299
  method: "POST",
256
300
  headers: { "Content-Type": "application/json" },
@@ -259,7 +303,9 @@ async function redeemInstallToken(opts) {
259
303
  });
260
304
  if (!resp.ok) {
261
305
  const text = await resp.text().catch(() => "");
262
- throw new Error(`daemon install-token redeem failed: ${resp.status} ${text}`);
306
+ const err = new Error(`daemon install-token redeem failed: ${resp.status} ${text}`);
307
+ err.status = resp.status;
308
+ throw err;
263
309
  }
264
310
  return parseDaemonTokenResponse(await resp.json(), opts.hubUrl);
265
311
  }
@@ -330,10 +376,10 @@ async function runDeviceCodeFlow(opts) {
330
376
  * plane (legacy P0 behavior — caller may still log a warning).
331
377
  *
332
378
  * Decision tree (plan §4.4 + §6.4):
333
- * 1. Have existing creds and no `--relogin` return existing record, even
334
- * when a dashboard `--install-token` is present. The token is one-time and
335
- * the generated install command should be safe to re-run after first login.
336
- * 2. No existing creds + `--install-token` → redeem the one-time dashboard ticket.
379
+ * 1. `--install-token` redeem the one-time dashboard ticket. If local
380
+ * user-auth exists, include its daemonInstanceId so Hub can re-authorize
381
+ * the same device instead of creating a new one.
382
+ * 2. Have existing creds and no `--relogin` → return existing record.
337
383
  * 3. `--relogin` → device-code login.
338
384
  * 4. No creds + TTY → device-code login.
339
385
  * 5. No creds + no TTY → exit 1 with the §6.4 hint.
@@ -359,22 +405,40 @@ async function ensureUserAuthForStart(args) {
359
405
  if (labelFlag && existing.label !== labelFlag) {
360
406
  console.error(`note: --label "${labelFlag}" ignored (already logged in as "${existing.label ?? "<unset>"}"); pass --relogin to change it`);
361
407
  }
362
- if (installToken) {
363
- console.error("note: --install-token ignored because daemon is already logged in; pass --relogin to re-bind");
364
- }
365
408
  return existing;
366
409
  }
367
410
  // Need a fresh login. Resolve hubUrl: explicit --hub > existing record > DEFAULT_HUB.
368
411
  const hubUrl = hubFlag ?? existing?.hubUrl ?? DEFAULT_HUB;
369
412
  const label = labelFlag ?? defaultLoginLabel();
370
413
  if (authAction === "install-token" && installToken) {
371
- const tok = await redeemInstallToken({ hubUrl, installToken, label });
372
- const record = userAuthFromTokenResponse(tok, { label });
414
+ let tok;
415
+ try {
416
+ tok = await redeemInstallToken({
417
+ hubUrl,
418
+ installToken,
419
+ label,
420
+ daemonInstanceId: existing?.daemonInstanceId,
421
+ });
422
+ }
423
+ catch (err) {
424
+ if (existing && !relogin && !existsSync(AUTH_EXPIRED_FLAG_PATH)) {
425
+ console.error(`note: --install-token could not be redeemed (${err instanceof Error ? err.message : String(err)}); reusing existing daemon auth`);
426
+ return existing;
427
+ }
428
+ throw err;
429
+ }
430
+ const record = userAuthFromTokenResponse(tok, {
431
+ label,
432
+ loggedInAt: existing?.daemonInstanceId && existing.daemonInstanceId === tok.daemonInstanceId
433
+ ? existing.loggedInAt
434
+ : undefined,
435
+ });
373
436
  saveUserAuth(record);
374
437
  clearAuthExpiredFlag();
375
438
  log.info("install-token flow: authorized", {
376
439
  userId: record.userId,
377
440
  daemonInstanceId: record.daemonInstanceId,
441
+ reusedExistingDaemonInstance: existing?.daemonInstanceId === record.daemonInstanceId,
378
442
  hubUrl: record.hubUrl,
379
443
  label,
380
444
  });
@@ -424,11 +488,6 @@ async function cmdStart(args) {
424
488
  relogin: args.flags.relogin === true,
425
489
  child: process.env.BOTCORD_DAEMON_CHILD === "1",
426
490
  });
427
- const existing = readPid();
428
- if (existing && pidAlive(existing)) {
429
- console.error(`daemon already running (pid ${existing})`);
430
- process.exit(1);
431
- }
432
491
  // Login MUST happen before fork — once detached, stdio is gone and the
433
492
  // user can't see the device code. We also run it for explicit
434
493
  // --foreground so an interactive user can log in without the fork dance.
@@ -436,6 +495,17 @@ async function cmdStart(args) {
436
495
  // var so we don't try to re-prompt for credentials it already has.
437
496
  if (process.env.BOTCORD_DAEMON_CHILD !== "1") {
438
497
  await ensureUserAuthForStart(args);
498
+ const existing = readPid();
499
+ if (existing && pidAlive(existing)) {
500
+ await stopExistingDaemonForRestart(existing);
501
+ }
502
+ }
503
+ else {
504
+ const existing = readPid();
505
+ if (existing && existing !== process.pid && pidAlive(existing)) {
506
+ console.error(`daemon already running (pid ${existing})`);
507
+ process.exit(1);
508
+ }
439
509
  }
440
510
  if (background) {
441
511
  // Detached child re-exec in foreground mode. The child writes the PID
@@ -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)
@@ -1,7 +1,7 @@
1
1
  export function resolveStartAuthAction(opts) {
2
- if (opts.existing && !opts.relogin)
3
- return "reuse-existing";
4
2
  if (opts.installToken)
5
3
  return "install-token";
4
+ if (opts.existing && !opts.relogin)
5
+ return "reuse-existing";
6
6
  return "device-code";
7
7
  }
package/dist/turn-text.js CHANGED
@@ -40,6 +40,13 @@ function replyDeliveryHint(msg) {
40
40
  ? THIRD_PARTY_REPLY_HINT
41
41
  : NON_OWNER_REPLY_HINT;
42
42
  }
43
+ function appendConversationFields(fields, msg) {
44
+ const conversationId = sanitizeSenderName(msg.conversation.id);
45
+ fields.push(`conversation_id: ${conversationId}`);
46
+ if (isThirdPartyConversation(msg.conversation.id)) {
47
+ fields.push(`channel: ${sanitizeSenderName(msg.channel)}`);
48
+ }
49
+ }
43
50
  /**
44
51
  * Read the `raw.batch` array emitted by the BotCord channel when inbox
45
52
  * drain groups multiple messages for the same `(room, topic)`. Returns the
@@ -138,6 +145,7 @@ export function composeBotCordUserTurn(msg) {
138
145
  `from: ${sanitizedSenderLabel}`,
139
146
  `to: ${msg.accountId}`,
140
147
  ];
148
+ appendConversationFields(headerFields, msg);
141
149
  if (isGroup && roomTitle) {
142
150
  const safeRoom = sanitizeSenderName(roomTitle.replace(/[\r\n]+/g, " "));
143
151
  headerFields.push(`room: ${safeRoom}`);
@@ -190,6 +198,7 @@ function composeBatchedTurn(msg, batch) {
190
198
  `[BotCord Messages (${batch.length} new)]`,
191
199
  `to: ${msg.accountId}`,
192
200
  ];
201
+ appendConversationFields(header, msg);
193
202
  if (isGroup && roomTitle) {
194
203
  const safeRoom = sanitizeSenderName(roomTitle.replace(/[\r\n]+/g, " "));
195
204
  header.push(`room: ${safeRoom}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botcord/daemon",
3
- "version": "0.2.51",
3
+ "version": "0.2.53",
4
4
  "description": "BotCord local daemon — bridges Hub inbox push to local Claude Code / Codex / Gemini CLIs",
5
5
  "type": "module",
6
6
  "bin": {