@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.
package/src/index.ts CHANGED
@@ -231,6 +231,44 @@ function pidAlive(pid: number): boolean {
231
231
  }
232
232
  }
233
233
 
234
+ async function waitForPidExit(pid: number, timeoutMs: number): Promise<boolean> {
235
+ const deadline = Date.now() + timeoutMs;
236
+ while (Date.now() < deadline) {
237
+ if (!pidAlive(pid)) return true;
238
+ await delay(100);
239
+ }
240
+ return !pidAlive(pid);
241
+ }
242
+
243
+ async function stopExistingDaemonForRestart(pid: number): Promise<void> {
244
+ if (pid === process.pid) return;
245
+ log.info("existing daemon found; restarting", { pid });
246
+ try {
247
+ process.kill(pid, "SIGTERM");
248
+ } catch {
249
+ try {
250
+ unlinkSync(PID_PATH);
251
+ } catch {
252
+ // ignore
253
+ }
254
+ return;
255
+ }
256
+ if (!(await waitForPidExit(pid, 5_000))) {
257
+ log.warn("existing daemon did not stop after SIGTERM; sending SIGKILL", { pid });
258
+ try {
259
+ process.kill(pid, "SIGKILL");
260
+ } catch {
261
+ // ignore
262
+ }
263
+ await waitForPidExit(pid, 2_000);
264
+ }
265
+ try {
266
+ unlinkSync(PID_PATH);
267
+ } catch {
268
+ // ignore
269
+ }
270
+ }
271
+
234
272
  /**
235
273
  * Load the daemon config, auto-creating `~/.botcord/daemon/config.json`
236
274
  * with sensible defaults on first run. `--agent` (repeated) pins explicit
@@ -323,9 +361,11 @@ async function redeemInstallToken(opts: {
323
361
  hubUrl: string;
324
362
  installToken: string;
325
363
  label?: string;
364
+ daemonInstanceId?: string;
326
365
  }): Promise<DaemonTokenResponse> {
327
366
  const body: Record<string, unknown> = { install_token: opts.installToken };
328
367
  if (opts.label) body.label = opts.label;
368
+ if (opts.daemonInstanceId) body.daemon_instance_id = opts.daemonInstanceId;
329
369
  const resp = await fetch(`${opts.hubUrl.replace(/\/+$/, "")}/daemon/auth/install-token`, {
330
370
  method: "POST",
331
371
  headers: { "Content-Type": "application/json" },
@@ -334,7 +374,9 @@ async function redeemInstallToken(opts: {
334
374
  });
335
375
  if (!resp.ok) {
336
376
  const text = await resp.text().catch(() => "");
337
- throw new Error(`daemon install-token redeem failed: ${resp.status} ${text}`);
377
+ const err = new Error(`daemon install-token redeem failed: ${resp.status} ${text}`);
378
+ (err as unknown as { status?: number }).status = resp.status;
379
+ throw err;
338
380
  }
339
381
  return parseDaemonTokenResponse(await resp.json(), opts.hubUrl);
340
382
  }
@@ -418,10 +460,10 @@ async function runDeviceCodeFlow(opts: {
418
460
  * plane (legacy P0 behavior — caller may still log a warning).
419
461
  *
420
462
  * Decision tree (plan §4.4 + §6.4):
421
- * 1. Have existing creds and no `--relogin` return existing record, even
422
- * when a dashboard `--install-token` is present. The token is one-time and
423
- * the generated install command should be safe to re-run after first login.
424
- * 2. No existing creds + `--install-token` → redeem the one-time dashboard ticket.
463
+ * 1. `--install-token` redeem the one-time dashboard ticket. If local
464
+ * user-auth exists, include its daemonInstanceId so Hub can re-authorize
465
+ * the same device instead of creating a new one.
466
+ * 2. Have existing creds and no `--relogin` → return existing record.
425
467
  * 3. `--relogin` → device-code login.
426
468
  * 4. No creds + TTY → device-code login.
427
469
  * 5. No creds + no TTY → exit 1 with the §6.4 hint.
@@ -452,9 +494,6 @@ async function ensureUserAuthForStart(args: ParsedArgs): Promise<UserAuthRecord
452
494
  `note: --label "${labelFlag}" ignored (already logged in as "${existing.label ?? "<unset>"}"); pass --relogin to change it`,
453
495
  );
454
496
  }
455
- if (installToken) {
456
- console.error("note: --install-token ignored because daemon is already logged in; pass --relogin to re-bind");
457
- }
458
497
  return existing;
459
498
  }
460
499
 
@@ -463,13 +502,37 @@ async function ensureUserAuthForStart(args: ParsedArgs): Promise<UserAuthRecord
463
502
  const label = labelFlag ?? defaultLoginLabel();
464
503
 
465
504
  if (authAction === "install-token" && installToken) {
466
- const tok = await redeemInstallToken({ hubUrl, installToken, label });
467
- const record = userAuthFromTokenResponse(tok, { label });
505
+ let tok: DaemonTokenResponse;
506
+ try {
507
+ tok = await redeemInstallToken({
508
+ hubUrl,
509
+ installToken,
510
+ label,
511
+ daemonInstanceId: existing?.daemonInstanceId,
512
+ });
513
+ } catch (err) {
514
+ if (existing && !relogin && !existsSync(AUTH_EXPIRED_FLAG_PATH)) {
515
+ console.error(
516
+ `note: --install-token could not be redeemed (${err instanceof Error ? err.message : String(err)}); reusing existing daemon auth`,
517
+ );
518
+ return existing;
519
+ }
520
+ throw err;
521
+ }
522
+ const record = userAuthFromTokenResponse(tok, {
523
+ label,
524
+ loggedInAt:
525
+ existing?.daemonInstanceId && existing.daemonInstanceId === tok.daemonInstanceId
526
+ ? existing.loggedInAt
527
+ : undefined,
528
+ });
468
529
  saveUserAuth(record);
469
530
  clearAuthExpiredFlag();
470
531
  log.info("install-token flow: authorized", {
471
532
  userId: record.userId,
472
533
  daemonInstanceId: record.daemonInstanceId,
534
+ reusedExistingDaemonInstance:
535
+ existing?.daemonInstanceId === record.daemonInstanceId,
473
536
  hubUrl: record.hubUrl,
474
537
  label,
475
538
  });
@@ -527,12 +590,6 @@ async function cmdStart(args: ParsedArgs): Promise<void> {
527
590
  child: process.env.BOTCORD_DAEMON_CHILD === "1",
528
591
  });
529
592
 
530
- const existing = readPid();
531
- if (existing && pidAlive(existing)) {
532
- console.error(`daemon already running (pid ${existing})`);
533
- process.exit(1);
534
- }
535
-
536
593
  // Login MUST happen before fork — once detached, stdio is gone and the
537
594
  // user can't see the device code. We also run it for explicit
538
595
  // --foreground so an interactive user can log in without the fork dance.
@@ -540,6 +597,16 @@ async function cmdStart(args: ParsedArgs): Promise<void> {
540
597
  // var so we don't try to re-prompt for credentials it already has.
541
598
  if (process.env.BOTCORD_DAEMON_CHILD !== "1") {
542
599
  await ensureUserAuthForStart(args);
600
+ const existing = readPid();
601
+ if (existing && pidAlive(existing)) {
602
+ await stopExistingDaemonForRestart(existing);
603
+ }
604
+ } else {
605
+ const existing = readPid();
606
+ if (existing && existing !== process.pid && pidAlive(existing)) {
607
+ console.error(`daemon already running (pid ${existing})`);
608
+ process.exit(1);
609
+ }
543
610
  }
544
611
 
545
612
  if (background) {
@@ -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,
package/src/start-auth.ts CHANGED
@@ -7,7 +7,7 @@ export function resolveStartAuthAction(opts: {
7
7
  relogin: boolean;
8
8
  installToken?: string;
9
9
  }): StartAuthAction {
10
- if (opts.existing && !opts.relogin) return "reuse-existing";
11
10
  if (opts.installToken) return "install-token";
11
+ if (opts.existing && !opts.relogin) return "reuse-existing";
12
12
  return "device-code";
13
13
  }
package/src/turn-text.ts CHANGED
@@ -76,6 +76,17 @@ function replyDeliveryHint(msg: GatewayInboundMessage): string {
76
76
  : NON_OWNER_REPLY_HINT;
77
77
  }
78
78
 
79
+ function appendConversationFields(
80
+ fields: string[],
81
+ msg: GatewayInboundMessage,
82
+ ): void {
83
+ const conversationId = sanitizeSenderName(msg.conversation.id);
84
+ fields.push(`conversation_id: ${conversationId}`);
85
+ if (isThirdPartyConversation(msg.conversation.id)) {
86
+ fields.push(`channel: ${sanitizeSenderName(msg.channel)}`);
87
+ }
88
+ }
89
+
79
90
  /** Minimal shape of one batched inbound entry. Matches the BotCord channel
80
91
  * `BatchedInboxRaw.batch[]` elements but expressed structurally so the
81
92
  * composer doesn't import channel internals. */
@@ -205,6 +216,7 @@ export function composeBotCordUserTurn(msg: GatewayInboundMessage): string {
205
216
  `from: ${sanitizedSenderLabel}`,
206
217
  `to: ${msg.accountId}`,
207
218
  ];
219
+ appendConversationFields(headerFields, msg);
208
220
  if (isGroup && roomTitle) {
209
221
  const safeRoom = sanitizeSenderName(roomTitle.replace(/[\r\n]+/g, " "));
210
222
  headerFields.push(`room: ${safeRoom}`);
@@ -267,6 +279,7 @@ function composeBatchedTurn(
267
279
  `[BotCord Messages (${batch.length} new)]`,
268
280
  `to: ${msg.accountId}`,
269
281
  ];
282
+ appendConversationFields(header, msg);
270
283
  if (isGroup && roomTitle) {
271
284
  const safeRoom = sanitizeSenderName(roomTitle.replace(/[\r\n]+/g, " "));
272
285
  header.push(`room: ${safeRoom}`);