@botcord/daemon 0.2.17 → 0.2.19

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.
@@ -7,6 +7,7 @@ const MAX_AUTH_FAILURES = 5;
7
7
  const SEEN_MESSAGES_CAP = 500;
8
8
  const OWNER_CHAT_PREFIX = "rm_oc_";
9
9
  const DM_ROOM_PREFIX = "rm_dm_";
10
+ const INBOX_POLL_LIMIT = 50;
10
11
  /** Default factory: wrap `loadStoredCredentials` + `new BotCordClient`. */
11
12
  function defaultClientFactory(input) {
12
13
  const credFile = input.credentialsPath ?? defaultCredentialsFile(input.agentId);
@@ -199,20 +200,41 @@ export function createBotCordChannel(options) {
199
200
  }
200
201
  return clientRef;
201
202
  }
202
- async function drainInbox(client, emit, log) {
203
- const resp = await client.pollInbox({ limit: 50, ack: false });
203
+ async function drainInbox(client, emit, log, trigger) {
204
+ const startedAt = Date.now();
205
+ const resp = await client.pollInbox({ limit: INBOX_POLL_LIMIT, ack: false });
204
206
  const msgs = resp.messages ?? [];
205
- log.info("botcord inbox drained", { count: msgs.length });
206
- if (msgs.length === 0)
207
+ let duplicateCount = 0;
208
+ let skippedCount = 0;
209
+ let emittedGroups = 0;
210
+ const logDrain = () => {
211
+ log.info("botcord inbox drained", {
212
+ trigger,
213
+ count: msgs.length,
214
+ responseCount: resp.count,
215
+ hasMore: resp.has_more,
216
+ limit: INBOX_POLL_LIMIT,
217
+ ack: false,
218
+ eligibleCount: eligible.length,
219
+ duplicateCount,
220
+ skippedCount,
221
+ emittedGroups,
222
+ durationMs: Date.now() - startedAt,
223
+ });
224
+ };
225
+ const eligible = [];
226
+ if (msgs.length === 0) {
227
+ logDrain();
207
228
  return;
229
+ }
208
230
  // First pass: ack duplicates/skipped messages so Hub stops requeueing,
209
231
  // and collect eligible messages preserving poll order. Grouping by
210
232
  // `(room_id, topic)` mirrors plugin's `handleInboxMessageBatch` — the
211
233
  // same conversation thread folds into one turn so the agent sees all
212
234
  // new messages at once instead of running N turns back-to-back.
213
- const eligible = [];
214
235
  for (const msg of msgs) {
215
236
  if (!rememberSeen(msg.hub_msg_id)) {
237
+ duplicateCount += 1;
216
238
  try {
217
239
  await client.ackMessages([msg.hub_msg_id]);
218
240
  }
@@ -226,6 +248,7 @@ export function createBotCordChannel(options) {
226
248
  accountId: options.accountId,
227
249
  });
228
250
  if (!normalized) {
251
+ skippedCount += 1;
229
252
  try {
230
253
  await client.ackMessages([msg.hub_msg_id]);
231
254
  }
@@ -236,8 +259,10 @@ export function createBotCordChannel(options) {
236
259
  }
237
260
  eligible.push(msg);
238
261
  }
239
- if (eligible.length === 0)
262
+ if (eligible.length === 0) {
263
+ logDrain();
240
264
  return;
265
+ }
241
266
  // Group by `(room_id, topic)`. Insertion order is the poll order, so
242
267
  // iterating the map yields groups with the same external chronology.
243
268
  const groups = new Map();
@@ -278,6 +303,7 @@ export function createBotCordChannel(options) {
278
303
  };
279
304
  try {
280
305
  await emit(envelope);
306
+ emittedGroups += 1;
281
307
  }
282
308
  catch (err) {
283
309
  log.error("botcord emit threw", {
@@ -286,6 +312,7 @@ export function createBotCordChannel(options) {
286
312
  });
287
313
  }
288
314
  }
315
+ logDrain();
289
316
  }
290
317
  function startWsLoop(client, ctx) {
291
318
  const { abortSignal, log, emit, setStatus } = ctx;
@@ -318,16 +345,19 @@ export function createBotCordChannel(options) {
318
345
  statusSnapshot = { ...statusSnapshot, ...patch };
319
346
  setStatus(patch);
320
347
  }
321
- async function fireInbox() {
348
+ async function fireInbox(trigger) {
322
349
  if (processing) {
323
350
  pendingUpdate = true;
351
+ log.debug("botcord inbox drain queued while previous drain is running", { trigger });
324
352
  return;
325
353
  }
326
354
  processing = true;
327
355
  try {
356
+ let currentTrigger = trigger;
328
357
  do {
329
358
  pendingUpdate = false;
330
- await drainInbox(client, emit, log);
359
+ await drainInbox(client, emit, log, currentTrigger);
360
+ currentTrigger = "coalesced_inbox_update";
331
361
  } while (pendingUpdate && running);
332
362
  }
333
363
  catch (err) {
@@ -413,7 +443,7 @@ export function createBotCordChannel(options) {
413
443
  lastError: null,
414
444
  });
415
445
  log.info("botcord ws authenticated", { agentId: msg.agent_id });
416
- void fireInbox();
446
+ void fireInbox("ws_auth_ok");
417
447
  keepaliveTimer = setInterval(() => {
418
448
  if (ws && ws.readyState === WebSocket.OPEN) {
419
449
  try {
@@ -427,7 +457,7 @@ export function createBotCordChannel(options) {
427
457
  }
428
458
  else if (msg.type === "inbox_update") {
429
459
  log.info("botcord ws inbox_update received");
430
- void fireInbox();
460
+ void fireInbox("ws_inbox_update");
431
461
  }
432
462
  else if (msg.type === "heartbeat" || msg.type === "pong") {
433
463
  // no-op
package/dist/index.js CHANGED
@@ -15,6 +15,7 @@ import { renderStatus } from "./status-render.js";
15
15
  import { appendNextParam } from "./url-utils.js";
16
16
  import { channelsFromDaemonConfig, defaultHttpFetcher, renderDoctor, runDoctor, } from "./doctor.js";
17
17
  import { clearWorkingMemory, readWorkingMemory, resolveMemoryDir, updateWorkingMemory, DEFAULT_SECTION, } from "./working-memory.js";
18
+ import { resolveStartAuthAction } from "./start-auth.js";
18
19
  import { discoverLocalOpenclawGateways, mergeOpenclawGateways, openclawDiscoveryConfigEnabled, } from "./openclaw-discovery.js";
19
20
  const ADAPTER_LIST = listAdapterIds().join("|");
20
21
  const DEFAULT_HUB = "https://api.botcord.chat";
@@ -329,9 +330,10 @@ async function runDeviceCodeFlow(opts) {
329
330
  * plane (legacy P0 behavior — caller may still log a warning).
330
331
  *
331
332
  * Decision tree (plan §4.4 + §6.4):
332
- * 1. Have existing creds, no `--relogin`, no `--install-token` → return existing record.
333
- * 2. `--install-token` (overrides existing creds they may be stale or
334
- * belong to a different account) redeem the one-time dashboard ticket.
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.
335
337
  * 3. `--relogin` → device-code login.
336
338
  * 4. No creds + TTY → device-code login.
337
339
  * 5. No creds + no TTY → exit 1 with the §6.4 hint.
@@ -342,7 +344,8 @@ async function ensureUserAuthForStart(args) {
342
344
  const installToken = typeof args.flags["install-token"] === "string" ? args.flags["install-token"] : undefined;
343
345
  const relogin = args.flags.relogin === true;
344
346
  const existing = safeLoadUserAuth();
345
- if (!relogin && !installToken && existing) {
347
+ const authAction = resolveStartAuthAction({ existing, relogin, installToken });
348
+ if (authAction === "reuse-existing" && existing) {
346
349
  // A previously-set auth-expired flag is stale by definition once the
347
350
  // operator runs `start` again — if creds genuinely don't work, the
348
351
  // control channel will re-write the flag on the next 4401/4403.
@@ -356,12 +359,15 @@ async function ensureUserAuthForStart(args) {
356
359
  if (labelFlag && existing.label !== labelFlag) {
357
360
  console.error(`note: --label "${labelFlag}" ignored (already logged in as "${existing.label ?? "<unset>"}"); pass --relogin to change it`);
358
361
  }
362
+ if (installToken) {
363
+ console.error("note: --install-token ignored because daemon is already logged in; pass --relogin to re-bind");
364
+ }
359
365
  return existing;
360
366
  }
361
367
  // Need a fresh login. Resolve hubUrl: explicit --hub > existing record > DEFAULT_HUB.
362
368
  const hubUrl = hubFlag ?? existing?.hubUrl ?? DEFAULT_HUB;
363
369
  const label = labelFlag ?? defaultLoginLabel();
364
- if (installToken) {
370
+ if (authAction === "install-token" && installToken) {
365
371
  const tok = await redeemInstallToken({ hubUrl, installToken, label });
366
372
  const record = userAuthFromTokenResponse(tok, { label });
367
373
  saveUserAuth(record);
package/dist/provision.js CHANGED
@@ -1137,26 +1137,51 @@ export async function collectRuntimeSnapshotAsync(opts = {}) {
1137
1137
  const timeoutMs = opts.timeoutMs ?? 3000;
1138
1138
  const capped = gateways.slice(0, RUNTIME_ENDPOINTS_CAP);
1139
1139
  const endpoints = await Promise.all(capped.map(async (g) => {
1140
+ if (localOpenclawAcpDisabled(g.url)) {
1141
+ return {
1142
+ name: g.name,
1143
+ url: g.url,
1144
+ reachable: false,
1145
+ status: "acp_disabled",
1146
+ error: "OpenClaw ACP runtime disabled",
1147
+ diagnostics: [
1148
+ {
1149
+ code: "acp_disabled",
1150
+ message: "OpenClaw config explicitly disables the ACP runtime",
1151
+ },
1152
+ ],
1153
+ };
1154
+ }
1140
1155
  try {
1141
1156
  const res = await probeOpenclawAgents(g, {
1142
1157
  probe: opts.wsProbe,
1143
1158
  timeoutMs,
1144
1159
  });
1145
- const entry = { name: g.name, url: g.url, reachable: res.ok };
1160
+ const entry = {
1161
+ name: g.name,
1162
+ url: g.url,
1163
+ reachable: res.ok,
1164
+ status: res.ok ? "reachable" : "unreachable",
1165
+ };
1146
1166
  if (res.version)
1147
1167
  entry.version = res.version;
1148
- if (res.error)
1168
+ if (res.error) {
1149
1169
  entry.error = res.error;
1170
+ entry.diagnostics = [{ code: "gateway_unreachable", message: res.error }];
1171
+ }
1150
1172
  if (res.agents)
1151
1173
  entry.agents = res.agents;
1152
1174
  return entry;
1153
1175
  }
1154
1176
  catch (err) {
1177
+ const message = err instanceof Error ? err.message : String(err);
1155
1178
  return {
1156
1179
  name: g.name,
1157
1180
  url: g.url,
1158
1181
  reachable: false,
1159
- error: err.message,
1182
+ status: "unreachable",
1183
+ error: message,
1184
+ diagnostics: [{ code: "probe_failed", message }],
1160
1185
  };
1161
1186
  }
1162
1187
  }));
@@ -0,0 +1,7 @@
1
+ import type { UserAuthRecord } from "./user-auth.js";
2
+ export type StartAuthAction = "reuse-existing" | "install-token" | "device-code";
3
+ export declare function resolveStartAuthAction(opts: {
4
+ existing: UserAuthRecord | null;
5
+ relogin: boolean;
6
+ installToken?: string;
7
+ }): StartAuthAction;
@@ -0,0 +1,7 @@
1
+ export function resolveStartAuthAction(opts) {
2
+ if (opts.existing && !opts.relogin)
3
+ return "reuse-existing";
4
+ if (opts.installToken)
5
+ return "install-token";
6
+ return "device-code";
7
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botcord/daemon",
3
- "version": "0.2.17",
3
+ "version": "0.2.19",
4
4
  "description": "BotCord local daemon — bridges Hub inbox push to local Claude Code / Codex / Gemini CLIs",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,4 +1,7 @@
1
1
  import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import path from "node:path";
2
5
 
3
6
  // Hoisted mock for `../adapters/runtimes.js` so each suite can stub
4
7
  // `detectRuntimes()` independently — we want coverage of the "empty
@@ -24,7 +27,12 @@ vi.mock("../adapters/runtimes.js", async () => {
24
27
  };
25
28
  });
26
29
 
27
- const { collectRuntimeSnapshot, clearRuntimeProbeCache, createProvisioner } = await import("../provision.js");
30
+ const {
31
+ collectRuntimeSnapshot,
32
+ collectRuntimeSnapshotAsync,
33
+ clearRuntimeProbeCache,
34
+ createProvisioner,
35
+ } = await import("../provision.js");
28
36
 
29
37
  beforeEach(() => {
30
38
  // The L1 probe is memoized for 30s in production; tests rotate the
@@ -94,6 +102,100 @@ describe("collectRuntimeSnapshot", () => {
94
102
  });
95
103
  });
96
104
 
105
+ describe("collectRuntimeSnapshotAsync", () => {
106
+ it("adds OpenClaw endpoint status and diagnostics", async () => {
107
+ setRuntimes([
108
+ {
109
+ id: "openclaw-acp",
110
+ displayName: "OpenClaw",
111
+ binary: "openclaw",
112
+ supportsRun: true,
113
+ result: { available: true, version: "0.1.0" },
114
+ },
115
+ ]);
116
+
117
+ const snap = await collectRuntimeSnapshotAsync({
118
+ cfg: {
119
+ openclawGateways: [
120
+ { name: "ok", url: "ws://127.0.0.1:18789" },
121
+ { name: "bad", url: "ws://127.0.0.1:16200" },
122
+ ],
123
+ },
124
+ wsProbe: async ({ url }) =>
125
+ url.includes("18789")
126
+ ? { ok: true, version: "gw-1", agents: [{ id: "main" }] }
127
+ : { ok: false, error: "connect rejected" },
128
+ });
129
+
130
+ const runtime = snap.runtimes.find((r) => r.id === "openclaw-acp");
131
+ expect(runtime?.endpoints).toEqual([
132
+ expect.objectContaining({
133
+ name: "ok",
134
+ reachable: true,
135
+ status: "reachable",
136
+ version: "gw-1",
137
+ agents: [{ id: "main" }],
138
+ }),
139
+ expect.objectContaining({
140
+ name: "bad",
141
+ reachable: false,
142
+ status: "unreachable",
143
+ error: "connect rejected",
144
+ diagnostics: [{ code: "gateway_unreachable", message: "connect rejected" }],
145
+ }),
146
+ ]);
147
+ });
148
+
149
+ it("reports acp_disabled without probing the gateway", async () => {
150
+ const tmp = mkdtempSync(path.join(tmpdir(), "daemon-runtime-openclaw-"));
151
+ const prevHome = process.env.HOME;
152
+ process.env.HOME = tmp;
153
+ try {
154
+ mkdirSync(path.join(tmp, ".openclaw"), { recursive: true });
155
+ writeFileSync(
156
+ path.join(tmp, ".openclaw", "openclaw.json"),
157
+ JSON.stringify({ acp: { enabled: false } }),
158
+ );
159
+ setRuntimes([
160
+ {
161
+ id: "openclaw-acp",
162
+ displayName: "OpenClaw",
163
+ binary: "openclaw",
164
+ supportsRun: true,
165
+ result: { available: true },
166
+ },
167
+ ]);
168
+ const wsProbe = vi.fn(async () => ({ ok: true }));
169
+
170
+ const snap = await collectRuntimeSnapshotAsync({
171
+ cfg: { openclawGateways: [{ name: "local", url: "ws://127.0.0.1:18789" }] },
172
+ wsProbe,
173
+ });
174
+
175
+ expect(wsProbe).not.toHaveBeenCalled();
176
+ const runtime = snap.runtimes.find((r) => r.id === "openclaw-acp");
177
+ expect(runtime?.endpoints).toEqual([
178
+ expect.objectContaining({
179
+ name: "local",
180
+ reachable: false,
181
+ status: "acp_disabled",
182
+ error: "OpenClaw ACP runtime disabled",
183
+ diagnostics: [
184
+ {
185
+ code: "acp_disabled",
186
+ message: "OpenClaw config explicitly disables the ACP runtime",
187
+ },
188
+ ],
189
+ }),
190
+ ]);
191
+ } finally {
192
+ if (prevHome === undefined) delete process.env.HOME;
193
+ else process.env.HOME = prevHome;
194
+ rmSync(tmp, { recursive: true, force: true });
195
+ }
196
+ });
197
+ });
198
+
97
199
  interface FakeGateway {
98
200
  addChannel: ReturnType<typeof vi.fn>;
99
201
  removeChannel: ReturnType<typeof vi.fn>;
@@ -0,0 +1,46 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { resolveStartAuthAction } from "../start-auth.js";
3
+ import type { UserAuthRecord } from "../user-auth.js";
4
+
5
+ const existingAuth: UserAuthRecord = {
6
+ version: 1,
7
+ userId: "usr_1",
8
+ daemonInstanceId: "dm_1",
9
+ hubUrl: "https://hub.example",
10
+ accessToken: "at",
11
+ refreshToken: "rt",
12
+ expiresAt: Date.now() + 60_000,
13
+ loggedInAt: new Date().toISOString(),
14
+ };
15
+
16
+ describe("resolveStartAuthAction", () => {
17
+ it("reuses existing auth even when a one-time install token is present", () => {
18
+ expect(
19
+ resolveStartAuthAction({
20
+ existing: existingAuth,
21
+ relogin: false,
22
+ installToken: "dit_expired",
23
+ }),
24
+ ).toBe("reuse-existing");
25
+ });
26
+
27
+ it("redeems an install token when no existing auth is available", () => {
28
+ expect(
29
+ resolveStartAuthAction({
30
+ existing: null,
31
+ relogin: false,
32
+ installToken: "dit_new",
33
+ }),
34
+ ).toBe("install-token");
35
+ });
36
+
37
+ it("allows --relogin to re-bind with an install token", () => {
38
+ expect(
39
+ resolveStartAuthAction({
40
+ existing: existingAuth,
41
+ relogin: true,
42
+ installToken: "dit_new",
43
+ }),
44
+ ).toBe("install-token");
45
+ });
46
+ });
@@ -182,6 +182,58 @@ describe("createBotCordChannel — inbox normalization", () => {
182
182
  return { emits, client, server };
183
183
  }
184
184
 
185
+ it("logs why an empty inbox drain ran", async () => {
186
+ const server = await startAuthOkServer();
187
+ const client = makeClient({
188
+ pollInbox: vi.fn().mockResolvedValue({ messages: [], count: 0, has_more: false }),
189
+ getHubUrl: vi.fn().mockReturnValue(server.url),
190
+ });
191
+ const channel = createBotCordChannel({
192
+ id: "botcord-main",
193
+ accountId: "ag_self",
194
+ agentId: "ag_self",
195
+ client,
196
+ hubBaseUrl: server.url,
197
+ });
198
+ const abort = new AbortController();
199
+ const log: GatewayLogger = {
200
+ ...silentLog,
201
+ info: vi.fn(),
202
+ };
203
+ const startPromise = channel.start({
204
+ config: stubConfig,
205
+ accountId: "ag_self",
206
+ abortSignal: abort.signal,
207
+ log,
208
+ emit: async () => {},
209
+ setStatus: () => {},
210
+ });
211
+ try {
212
+ await vi.waitFor(() => {
213
+ expect(log.info).toHaveBeenCalledWith(
214
+ "botcord inbox drained",
215
+ expect.objectContaining({
216
+ trigger: "ws_auth_ok",
217
+ count: 0,
218
+ responseCount: 0,
219
+ hasMore: false,
220
+ limit: 50,
221
+ ack: false,
222
+ eligibleCount: 0,
223
+ duplicateCount: 0,
224
+ skippedCount: 0,
225
+ emittedGroups: 0,
226
+ durationMs: expect.any(Number),
227
+ }),
228
+ );
229
+ });
230
+ } finally {
231
+ abort.abort();
232
+ await startPromise;
233
+ await server.close();
234
+ }
235
+ });
236
+
185
237
  it("maps a group-room InboxMessage to a GatewayInboundMessage", async () => {
186
238
  const { emits, server } = await startWithInbox([
187
239
  makeInbox({
@@ -28,6 +28,9 @@ const MAX_AUTH_FAILURES = 5;
28
28
  const SEEN_MESSAGES_CAP = 500;
29
29
  const OWNER_CHAT_PREFIX = "rm_oc_";
30
30
  const DM_ROOM_PREFIX = "rm_dm_";
31
+ const INBOX_POLL_LIMIT = 50;
32
+
33
+ type InboxDrainTrigger = "ws_auth_ok" | "ws_inbox_update" | "coalesced_inbox_update";
31
34
 
32
35
  /** Minimal surface the adapter needs from `BotCordClient`. Matches the subset used at runtime. */
33
36
  export interface BotCordChannelClient {
@@ -305,20 +308,43 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
305
308
  client: BotCordChannelClient,
306
309
  emit: (env: GatewayInboundEnvelope) => Promise<void>,
307
310
  log: GatewayLogger,
311
+ trigger: InboxDrainTrigger,
308
312
  ): Promise<void> {
309
- const resp = await client.pollInbox({ limit: 50, ack: false });
313
+ const startedAt = Date.now();
314
+ const resp = await client.pollInbox({ limit: INBOX_POLL_LIMIT, ack: false });
310
315
  const msgs = resp.messages ?? [];
311
- log.info("botcord inbox drained", { count: msgs.length });
312
- if (msgs.length === 0) return;
316
+ let duplicateCount = 0;
317
+ let skippedCount = 0;
318
+ let emittedGroups = 0;
319
+ const logDrain = () => {
320
+ log.info("botcord inbox drained", {
321
+ trigger,
322
+ count: msgs.length,
323
+ responseCount: resp.count,
324
+ hasMore: resp.has_more,
325
+ limit: INBOX_POLL_LIMIT,
326
+ ack: false,
327
+ eligibleCount: eligible.length,
328
+ duplicateCount,
329
+ skippedCount,
330
+ emittedGroups,
331
+ durationMs: Date.now() - startedAt,
332
+ });
333
+ };
334
+ const eligible: InboxMessage[] = [];
335
+ if (msgs.length === 0) {
336
+ logDrain();
337
+ return;
338
+ }
313
339
 
314
340
  // First pass: ack duplicates/skipped messages so Hub stops requeueing,
315
341
  // and collect eligible messages preserving poll order. Grouping by
316
342
  // `(room_id, topic)` mirrors plugin's `handleInboxMessageBatch` — the
317
343
  // same conversation thread folds into one turn so the agent sees all
318
344
  // new messages at once instead of running N turns back-to-back.
319
- const eligible: InboxMessage[] = [];
320
345
  for (const msg of msgs) {
321
346
  if (!rememberSeen(msg.hub_msg_id)) {
347
+ duplicateCount += 1;
322
348
  try {
323
349
  await client.ackMessages([msg.hub_msg_id]);
324
350
  } catch (err) {
@@ -331,6 +357,7 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
331
357
  accountId: options.accountId,
332
358
  });
333
359
  if (!normalized) {
360
+ skippedCount += 1;
334
361
  try {
335
362
  await client.ackMessages([msg.hub_msg_id]);
336
363
  } catch (err) {
@@ -341,7 +368,10 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
341
368
  eligible.push(msg);
342
369
  }
343
370
 
344
- if (eligible.length === 0) return;
371
+ if (eligible.length === 0) {
372
+ logDrain();
373
+ return;
374
+ }
345
375
 
346
376
  // Group by `(room_id, topic)`. Insertion order is the poll order, so
347
377
  // iterating the map yields groups with the same external chronology.
@@ -381,6 +411,7 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
381
411
  };
382
412
  try {
383
413
  await emit(envelope);
414
+ emittedGroups += 1;
384
415
  } catch (err) {
385
416
  log.error("botcord emit threw", {
386
417
  hubMsgIds: hubIds,
@@ -388,6 +419,7 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
388
419
  });
389
420
  }
390
421
  }
422
+ logDrain();
391
423
  }
392
424
 
393
425
  function startWsLoop(
@@ -429,16 +461,19 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
429
461
  setStatus(patch);
430
462
  }
431
463
 
432
- async function fireInbox() {
464
+ async function fireInbox(trigger: InboxDrainTrigger) {
433
465
  if (processing) {
434
466
  pendingUpdate = true;
467
+ log.debug("botcord inbox drain queued while previous drain is running", { trigger });
435
468
  return;
436
469
  }
437
470
  processing = true;
438
471
  try {
472
+ let currentTrigger = trigger;
439
473
  do {
440
474
  pendingUpdate = false;
441
- await drainInbox(client, emit, log);
475
+ await drainInbox(client, emit, log, currentTrigger);
476
+ currentTrigger = "coalesced_inbox_update";
442
477
  } while (pendingUpdate && running);
443
478
  } catch (err) {
444
479
  log.error("botcord inbox drain failed", { err: String(err) });
@@ -521,7 +556,7 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
521
556
  lastError: null,
522
557
  });
523
558
  log.info("botcord ws authenticated", { agentId: msg.agent_id });
524
- void fireInbox();
559
+ void fireInbox("ws_auth_ok");
525
560
  keepaliveTimer = setInterval(() => {
526
561
  if (ws && ws.readyState === WebSocket.OPEN) {
527
562
  try {
@@ -533,7 +568,7 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
533
568
  }, KEEPALIVE_INTERVAL);
534
569
  } else if (msg.type === "inbox_update") {
535
570
  log.info("botcord ws inbox_update received");
536
- void fireInbox();
571
+ void fireInbox("ws_inbox_update");
537
572
  } else if (msg.type === "heartbeat" || msg.type === "pong") {
538
573
  // no-op
539
574
  } else if (msg.type === "error" || msg.type === "auth_failed") {
package/src/index.ts CHANGED
@@ -57,6 +57,7 @@ import {
57
57
  updateWorkingMemory,
58
58
  DEFAULT_SECTION,
59
59
  } from "./working-memory.js";
60
+ import { resolveStartAuthAction } from "./start-auth.js";
60
61
  import {
61
62
  discoverLocalOpenclawGateways,
62
63
  mergeOpenclawGateways,
@@ -417,9 +418,10 @@ async function runDeviceCodeFlow(opts: {
417
418
  * plane (legacy P0 behavior — caller may still log a warning).
418
419
  *
419
420
  * Decision tree (plan §4.4 + §6.4):
420
- * 1. Have existing creds, no `--relogin`, no `--install-token` → return existing record.
421
- * 2. `--install-token` (overrides existing creds they may be stale or
422
- * belong to a different account) redeem the one-time dashboard ticket.
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.
423
425
  * 3. `--relogin` → device-code login.
424
426
  * 4. No creds + TTY → device-code login.
425
427
  * 5. No creds + no TTY → exit 1 with the §6.4 hint.
@@ -432,8 +434,9 @@ async function ensureUserAuthForStart(args: ParsedArgs): Promise<UserAuthRecord
432
434
  const relogin = args.flags.relogin === true;
433
435
 
434
436
  const existing = safeLoadUserAuth();
437
+ const authAction = resolveStartAuthAction({ existing, relogin, installToken });
435
438
 
436
- if (!relogin && !installToken && existing) {
439
+ if (authAction === "reuse-existing" && existing) {
437
440
  // A previously-set auth-expired flag is stale by definition once the
438
441
  // operator runs `start` again — if creds genuinely don't work, the
439
442
  // control channel will re-write the flag on the next 4401/4403.
@@ -449,6 +452,9 @@ async function ensureUserAuthForStart(args: ParsedArgs): Promise<UserAuthRecord
449
452
  `note: --label "${labelFlag}" ignored (already logged in as "${existing.label ?? "<unset>"}"); pass --relogin to change it`,
450
453
  );
451
454
  }
455
+ if (installToken) {
456
+ console.error("note: --install-token ignored because daemon is already logged in; pass --relogin to re-bind");
457
+ }
452
458
  return existing;
453
459
  }
454
460
 
@@ -456,7 +462,7 @@ async function ensureUserAuthForStart(args: ParsedArgs): Promise<UserAuthRecord
456
462
  const hubUrl = hubFlag ?? existing?.hubUrl ?? DEFAULT_HUB;
457
463
  const label = labelFlag ?? defaultLoginLabel();
458
464
 
459
- if (installToken) {
465
+ if (authAction === "install-token" && installToken) {
460
466
  const tok = await redeemInstallToken({ hubUrl, installToken, label });
461
467
  const record = userAuthFromTokenResponse(tok, { label });
462
468
  saveUserAuth(record);
package/src/provision.ts CHANGED
@@ -1324,22 +1324,48 @@ export async function collectRuntimeSnapshotAsync(opts: {
1324
1324
  const capped = gateways.slice(0, RUNTIME_ENDPOINTS_CAP);
1325
1325
  const endpoints = await Promise.all(
1326
1326
  capped.map(async (g) => {
1327
+ if (localOpenclawAcpDisabled(g.url)) {
1328
+ return {
1329
+ name: g.name,
1330
+ url: g.url,
1331
+ reachable: false,
1332
+ status: "acp_disabled",
1333
+ error: "OpenClaw ACP runtime disabled",
1334
+ diagnostics: [
1335
+ {
1336
+ code: "acp_disabled",
1337
+ message: "OpenClaw config explicitly disables the ACP runtime",
1338
+ },
1339
+ ],
1340
+ };
1341
+ }
1327
1342
  try {
1328
1343
  const res = await probeOpenclawAgents(g, {
1329
1344
  probe: opts.wsProbe,
1330
1345
  timeoutMs,
1331
1346
  });
1332
- const entry: any = { name: g.name, url: g.url, reachable: res.ok };
1347
+ const entry: any = {
1348
+ name: g.name,
1349
+ url: g.url,
1350
+ reachable: res.ok,
1351
+ status: res.ok ? "reachable" : "unreachable",
1352
+ };
1333
1353
  if (res.version) entry.version = res.version;
1334
- if (res.error) entry.error = res.error;
1354
+ if (res.error) {
1355
+ entry.error = res.error;
1356
+ entry.diagnostics = [{ code: "gateway_unreachable", message: res.error }];
1357
+ }
1335
1358
  if (res.agents) entry.agents = res.agents;
1336
1359
  return entry;
1337
1360
  } catch (err) {
1361
+ const message = err instanceof Error ? err.message : String(err);
1338
1362
  return {
1339
1363
  name: g.name,
1340
1364
  url: g.url,
1341
1365
  reachable: false,
1342
- error: (err as Error).message,
1366
+ status: "unreachable",
1367
+ error: message,
1368
+ diagnostics: [{ code: "probe_failed", message }],
1343
1369
  };
1344
1370
  }
1345
1371
  }),
@@ -0,0 +1,13 @@
1
+ import type { UserAuthRecord } from "./user-auth.js";
2
+
3
+ export type StartAuthAction = "reuse-existing" | "install-token" | "device-code";
4
+
5
+ export function resolveStartAuthAction(opts: {
6
+ existing: UserAuthRecord | null;
7
+ relogin: boolean;
8
+ installToken?: string;
9
+ }): StartAuthAction {
10
+ if (opts.existing && !opts.relogin) return "reuse-existing";
11
+ if (opts.installToken) return "install-token";
12
+ return "device-code";
13
+ }