@botcord/daemon 0.2.75 → 0.2.76

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.
Files changed (62) hide show
  1. package/dist/cloud-auth.d.ts +47 -0
  2. package/dist/cloud-auth.js +51 -0
  3. package/dist/cloud-daemon.d.ts +43 -0
  4. package/dist/cloud-daemon.js +252 -0
  5. package/dist/cloud-mode.d.ts +45 -0
  6. package/dist/cloud-mode.js +55 -0
  7. package/dist/cloud-settle.d.ts +81 -0
  8. package/dist/cloud-settle.js +100 -0
  9. package/dist/daemon-singleton.d.ts +26 -0
  10. package/dist/daemon-singleton.js +91 -0
  11. package/dist/daemon.d.ts +1 -1
  12. package/dist/daemon.js +15 -6
  13. package/dist/doctor.d.ts +4 -1
  14. package/dist/doctor.js +15 -4
  15. package/dist/gateway/channels/botcord.d.ts +1 -1
  16. package/dist/gateway/channels/botcord.js +48 -5
  17. package/dist/gateway/dispatcher.d.ts +34 -1
  18. package/dist/gateway/dispatcher.js +277 -20
  19. package/dist/gateway/gateway.d.ts +9 -1
  20. package/dist/gateway/gateway.js +4 -1
  21. package/dist/gateway/runtime-errors.d.ts +6 -0
  22. package/dist/gateway/runtime-errors.js +14 -0
  23. package/dist/gateway/runtimes/claude-code.d.ts +8 -0
  24. package/dist/gateway/runtimes/claude-code.js +92 -4
  25. package/dist/gateway/runtimes/deepseek-tui.js +19 -5
  26. package/dist/gateway/transcript.d.ts +1 -1
  27. package/dist/gateway/types.d.ts +33 -0
  28. package/dist/index.js +71 -80
  29. package/dist/provision.d.ts +2 -0
  30. package/dist/provision.js +39 -1
  31. package/dist/status-render.js +17 -0
  32. package/package.json +2 -2
  33. package/src/__tests__/cloud-auth.test.ts +42 -0
  34. package/src/__tests__/cloud-daemon.test.ts +237 -0
  35. package/src/__tests__/cloud-mode.test.ts +65 -0
  36. package/src/__tests__/cloud-settle.test.ts +287 -0
  37. package/src/__tests__/daemon-singleton.test.ts +89 -0
  38. package/src/__tests__/doctor.test.ts +34 -0
  39. package/src/__tests__/runtime-discovery.test.ts +90 -0
  40. package/src/__tests__/status-render.test.ts +34 -0
  41. package/src/cloud-auth.ts +78 -0
  42. package/src/cloud-daemon.ts +338 -0
  43. package/src/cloud-mode.ts +70 -0
  44. package/src/cloud-settle.ts +182 -0
  45. package/src/daemon-singleton.ts +122 -0
  46. package/src/daemon.ts +18 -5
  47. package/src/doctor.ts +18 -5
  48. package/src/gateway/__tests__/botcord-channel.test.ts +74 -0
  49. package/src/gateway/__tests__/claude-code-adapter.test.ts +101 -1
  50. package/src/gateway/__tests__/deepseek-tui-adapter.test.ts +19 -0
  51. package/src/gateway/__tests__/dispatcher.test.ts +120 -0
  52. package/src/gateway/channels/botcord.ts +54 -7
  53. package/src/gateway/dispatcher.ts +354 -21
  54. package/src/gateway/gateway.ts +16 -1
  55. package/src/gateway/runtime-errors.ts +15 -0
  56. package/src/gateway/runtimes/claude-code.ts +98 -2
  57. package/src/gateway/runtimes/deepseek-tui.ts +23 -5
  58. package/src/gateway/transcript.ts +1 -1
  59. package/src/gateway/types.ts +34 -0
  60. package/src/index.ts +83 -74
  61. package/src/provision.ts +45 -1
  62. package/src/status-render.ts +24 -0
@@ -1,8 +1,26 @@
1
+ import { execFileSync } from "node:child_process";
1
2
  import path from "node:path";
2
3
  import { NdjsonStreamAdapter } from "./ndjson-stream.js";
4
+ import { consoleLogger } from "../log.js";
5
+ import { looksLikeRuntimeAuthFailure } from "../runtime-errors.js";
3
6
  import { firstExistingPath, readCommandVersion, resolveCommandOnPath, resolveHomePath, } from "./probe.js";
4
7
  const CLAUDE_DESKTOP_CLI_RELATIVE_PATH = path.join("Applications", "Claude Code URL Handler.app", "Contents", "MacOS", "claude");
5
8
  const CLAUDE_DESKTOP_CLI_SYSTEM_PATH = "/Applications/Claude Code URL Handler.app/Contents/MacOS/claude";
9
+ const log = consoleLogger;
10
+ const CLAUDE_CODE_AUTH_ENV_DENYLIST = [
11
+ "ANTHROPIC_API_KEY",
12
+ "ANTHROPIC_AUTH_TOKEN",
13
+ "ANTHROPIC_BASE_URL",
14
+ "ANTHROPIC_CUSTOM_HEADERS",
15
+ "CLAUDE_CODE_OAUTH_TOKEN",
16
+ ];
17
+ export function scrubClaudeCodeAuthEnv(env) {
18
+ const out = { ...env };
19
+ for (const key of CLAUDE_CODE_AUTH_ENV_DENYLIST) {
20
+ delete out[key];
21
+ }
22
+ return out;
23
+ }
6
24
  function isValidClaudeSessionId(sessionId) {
7
25
  if (sessionId.length === 0 || sessionId.length > 512)
8
26
  return false;
@@ -111,6 +129,59 @@ export function probeClaude(deps = {}) {
111
129
  version: readCommandVersion(command, [], deps) ?? undefined,
112
130
  };
113
131
  }
132
+ export function probeClaudeAuth(deps = {}) {
133
+ const command = resolveClaudeCommand(deps);
134
+ if (!command)
135
+ return { checked: false, ok: false, message: "claude command not found" };
136
+ return runClaudeAuthProbe(command, deps);
137
+ }
138
+ function runClaudeAuthProbe(command, deps = {}) {
139
+ const execFn = deps.execFileSyncFn ?? execFileSync;
140
+ const env = scrubClaudeCodeAuthEnv(deps.env ?? process.env);
141
+ try {
142
+ const raw = execFn(command, ["-p", "ping", "--output-format", "stream-json"], {
143
+ stdio: ["ignore", "pipe", "pipe"],
144
+ env,
145
+ timeout: 20_000,
146
+ });
147
+ const output = Buffer.isBuffer(raw) ? raw.toString("utf8") : String(raw ?? "");
148
+ const authFailure = claudeAuthFailureFromOutput(output);
149
+ if (authFailure)
150
+ return { checked: true, ok: false, message: authFailure };
151
+ return { checked: true, ok: true, message: "claude-code auth ok" };
152
+ }
153
+ catch (err) {
154
+ const e = err;
155
+ const output = `${bufferishToString(e.stdout)}\n${bufferishToString(e.stderr)}`.trim();
156
+ const authFailure = claudeAuthFailureFromOutput(output);
157
+ return {
158
+ checked: true,
159
+ ok: false,
160
+ message: authFailure || e.message || "claude-code auth probe failed",
161
+ };
162
+ }
163
+ }
164
+ function bufferishToString(raw) {
165
+ return Buffer.isBuffer(raw) ? raw.toString("utf8") : String(raw ?? "");
166
+ }
167
+ function claudeAuthFailureFromOutput(output) {
168
+ for (const line of output.split(/\r?\n/)) {
169
+ const s = line.trim();
170
+ if (!s)
171
+ continue;
172
+ try {
173
+ const obj = JSON.parse(s);
174
+ if (obj.type === "result" && typeof obj.result === "string" && looksLikeRuntimeAuthFailure(obj.result)) {
175
+ return obj.result;
176
+ }
177
+ }
178
+ catch {
179
+ if (looksLikeRuntimeAuthFailure(s))
180
+ return s;
181
+ }
182
+ }
183
+ return looksLikeRuntimeAuthFailure(output) ? output : null;
184
+ }
114
185
  /**
115
186
  * Claude Code adapter — spawns `claude -p "<text>" --output-format stream-json`
116
187
  * (with `--resume <sid>` when available) and parses the ndjson stream.
@@ -180,6 +251,9 @@ export class ClaudeCodeAdapter extends NdjsonStreamAdapter {
180
251
  args.push(...extraArgs);
181
252
  return args;
182
253
  }
254
+ spawnEnv(opts) {
255
+ return scrubClaudeCodeAuthEnv(super.spawnEnv(opts));
256
+ }
183
257
  handleEvent(raw, ctx) {
184
258
  const obj = raw;
185
259
  // Emit a thinking lifecycle hint BEFORE the block so the dispatcher's
@@ -204,10 +278,24 @@ export class ClaudeCodeAdapter extends NdjsonStreamAdapter {
204
278
  if (typeof obj.total_cost_usd === "number")
205
279
  ctx.state.costUsd = obj.total_cost_usd;
206
280
  if (obj.subtype === "success") {
207
- if (typeof obj.session_id === "string")
208
- ctx.state.newSessionId = obj.session_id;
209
- if (typeof obj.result === "string")
210
- ctx.state.finalText = obj.result;
281
+ const result = typeof obj.result === "string" ? obj.result : "";
282
+ const looksLikeAuthFailure = obj.total_cost_usd === 0 && looksLikeRuntimeAuthFailure(result);
283
+ if (looksLikeAuthFailure) {
284
+ log.error("claude-code authentication failed; check ~/.claude login or unset stale Anthropic env vars", {
285
+ error: result,
286
+ });
287
+ ctx.state.newSessionId = "";
288
+ ctx.state.finalText = "";
289
+ ctx.state.assistantTextChunks = [];
290
+ ctx.state.assistantTextBytes = 0;
291
+ ctx.state.errorText = result;
292
+ }
293
+ else {
294
+ if (typeof obj.session_id === "string")
295
+ ctx.state.newSessionId = obj.session_id;
296
+ if (typeof obj.result === "string")
297
+ ctx.state.finalText = obj.result;
298
+ }
211
299
  }
212
300
  else {
213
301
  // Non-success result (e.g. resume targeted a missing UUID). Claude Code
@@ -308,14 +308,14 @@ export class DeepseekTuiAdapter {
308
308
  else if (eventName === "item.delta" && payload?.payload?.kind === "agent_message") {
309
309
  append(stringField(payload.payload, "delta") ?? "");
310
310
  }
311
- if (eventName === "turn.started") {
311
+ if (eventName === "turn.started" || embeddedDeepseekEvent(payload) === "turn.started") {
312
312
  opts.onStatus?.({ kind: "thinking", phase: "started", label: "Thinking" });
313
313
  }
314
314
  else if (eventName === "tool.started" || isToolStarted(payload)) {
315
315
  const label = stringField(payload, "name") ?? stringField(payload?.payload?.tool, "name") ?? "tool";
316
316
  opts.onStatus?.({ kind: "thinking", phase: "updated", label });
317
317
  }
318
- else if (eventName === "turn.completed" || eventName === "done") {
318
+ else if (isDeepseekTerminalEvent(eventName, payload)) {
319
319
  opts.onStatus?.({ kind: "thinking", phase: "stopped" });
320
320
  return true;
321
321
  }
@@ -383,14 +383,28 @@ function normalizeDeepseekEvent(eventName, payload, seq) {
383
383
  if (eventName === "item.delta" && payload?.payload?.kind === "agent_message") {
384
384
  return { raw: { event: eventName, payload }, kind: "assistant_text", seq };
385
385
  }
386
- if (eventName === "turn.started" || eventName === "status") {
386
+ if (eventName === "turn.started" || eventName === "status" || embeddedDeepseekEvent(payload) === "turn.started") {
387
387
  return { raw: { event: eventName, payload }, kind: "system", seq };
388
388
  }
389
- if (eventName === "error" || eventName === "turn.completed" || eventName === "done") {
389
+ if (eventName === "error" || isDeepseekTerminalEvent(eventName, payload)) {
390
390
  return { raw: { event: eventName, payload }, kind: "other", seq };
391
391
  }
392
392
  return null;
393
393
  }
394
+ function embeddedDeepseekEvent(payload) {
395
+ return stringField(payload, "event") ?? stringField(payload?.payload, "event");
396
+ }
397
+ function isDeepseekTerminalEvent(eventName, payload) {
398
+ const embedded = embeddedDeepseekEvent(payload);
399
+ return (eventName === "turn.completed" ||
400
+ eventName === "turn.finished" ||
401
+ eventName === "turn.done" ||
402
+ eventName === "done" ||
403
+ embedded === "turn.completed" ||
404
+ embedded === "turn.finished" ||
405
+ embedded === "turn.done" ||
406
+ embedded === "done");
407
+ }
394
408
  function isToolStarted(payload) {
395
409
  return payload?.event === "item.started" && !!payload?.payload?.tool;
396
410
  }
@@ -411,7 +425,7 @@ function extractDeepseekError(eventName, payload) {
411
425
  stringField(payload?.payload?.item, "summary") ??
412
426
  stringField(payload?.payload, "error"));
413
427
  }
414
- if (eventName === "turn.completed") {
428
+ if (isDeepseekTerminalEvent(eventName, payload)) {
415
429
  const turn = payload?.payload?.turn ?? payload?.turn;
416
430
  const status = stringField(turn, "status");
417
431
  const err = stringField(turn, "error");
@@ -84,7 +84,7 @@ export interface OutboundTranscriptRecord extends TranscriptRecordBase {
84
84
  }
85
85
  export interface TurnErrorTranscriptRecord extends TranscriptRecordBase {
86
86
  kind: "turn_error";
87
- phase: "runtime" | "timeout";
87
+ phase: "runtime" | "timeout" | "budget";
88
88
  error: string;
89
89
  durationMs: number;
90
90
  }
@@ -196,10 +196,25 @@ export interface TurnStatusSnapshot {
196
196
  cwd: string;
197
197
  startedAt: number;
198
198
  }
199
+ /** Per-runtime auth circuit breaker state exposed through daemon snapshots. */
200
+ export interface RuntimeCircuitBreakerSnapshot {
201
+ key: string;
202
+ runtime: string;
203
+ channel: string;
204
+ accountId: string;
205
+ conversationId: string;
206
+ threadId?: string | null;
207
+ failures: number;
208
+ openedAt: number;
209
+ blockedUntil: number;
210
+ lastFailureAt: number;
211
+ lastError: string;
212
+ }
199
213
  /** Aggregate gateway state combining channel and turn snapshots. */
200
214
  export interface GatewayRuntimeSnapshot {
201
215
  channels: Record<string, ChannelStatusSnapshot>;
202
216
  turns: Record<string, TurnStatusSnapshot>;
217
+ runtimeCircuitBreakers?: Record<string, RuntimeCircuitBreakerSnapshot>;
203
218
  }
204
219
  /** Context passed to `ChannelAdapter.start()` for its lifetime. */
205
220
  export interface ChannelStartContext {
@@ -322,6 +337,15 @@ export interface RuntimeRunOptions {
322
337
  systemContext?: string;
323
338
  /** Channel-agnostic bag for dispatch-time data (traceId, channel, conversation, etc.). */
324
339
  context?: Record<string, unknown>;
340
+ /**
341
+ * Cloud Agent run budget. Present only for Hub-issued `cloud_run` envelopes.
342
+ * Dispatcher enforces wall time and tool-call count; runtimes may also use it
343
+ * to apply provider-native limits when available.
344
+ */
345
+ budget?: {
346
+ maxWallTimeMs?: number;
347
+ maxToolCalls?: number;
348
+ };
325
349
  /** Called for every parsed block while the turn is in progress. */
326
350
  onBlock?: (block: StreamBlock) => void;
327
351
  /**
@@ -359,6 +383,15 @@ export interface RuntimeRunResult {
359
383
  costUsd?: number;
360
384
  /** Populated when the runtime reported a hard error. */
361
385
  error?: string;
386
+ /**
387
+ * Optional token-count breakdown reported by the runtime. Used by the
388
+ * cloud daemon's ``cloud_run`` settle hook to charge a run against the
389
+ * user's Cloud Credits. Adapters that don't surface usage data leave
390
+ * these undefined; the settle path treats undefined as ``0``.
391
+ */
392
+ inputCacheHitTokens?: number;
393
+ inputCacheMissTokens?: number;
394
+ outputTokens?: number;
362
395
  }
363
396
  /** Detection result for whether a runtime binary/SDK is usable on this machine. */
364
397
  export interface RuntimeProbeResult {
package/dist/index.js CHANGED
@@ -1,10 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn } from "node:child_process";
3
- import { existsSync, readFileSync, writeFileSync, unlinkSync, readdirSync, statSync, rmSync } from "node:fs";
3
+ import { existsSync, readFileSync, unlinkSync, readdirSync, statSync, rmSync } from "node:fs";
4
4
  import { homedir, hostname } from "node:os";
5
5
  import path from "node:path";
6
6
  import { augmentProcessPath } from "./path-env.js";
7
- import { loadConfig, saveConfig, initDefaultConfig, resolveConfiguredAgentIds, PID_PATH, SNAPSHOT_PATH, CONFIG_FILE_PATH, CONFIG_MISSING, } from "./config.js";
7
+ import { loadConfig, saveConfig, initDefaultConfig, resolveConfiguredAgentIds, SNAPSHOT_PATH, CONFIG_FILE_PATH, CONFIG_MISSING, } from "./config.js";
8
+ import { ensureNoOtherDaemonFromPidFile, pidAlive, readPid, removePidFile, stopDaemonFromPidFileForRestart, writeCurrentPid, } from "./daemon-singleton.js";
8
9
  import { resolveBootAgents } from "./agent-discovery.js";
9
10
  import { defaultTranscriptRoot, resolveTranscriptEnabled, transcriptAgentRoot, transcriptFilePath, } from "./gateway/index.js";
10
11
  import { startDaemon } from "./daemon.js";
@@ -19,6 +20,8 @@ import { clearWorkingMemory, readWorkingMemory, resolveMemoryDir, updateWorkingM
19
20
  import { createDiagnosticBundle } from "./diagnostics.js";
20
21
  import { resolveStartAuthAction } from "./start-auth.js";
21
22
  import { discoverLocalOpenclawGateways, mergeOpenclawGateways, openclawDiscoveryConfigEnabled, } from "./openclaw-discovery.js";
23
+ import { isCloudMode, loadCloudModeConfig } from "./cloud-mode.js";
24
+ import { startCloudDaemon } from "./cloud-daemon.js";
22
25
  augmentProcessPath();
23
26
  const ADAPTER_LIST = listAdapterIds().join("|");
24
27
  const DEFAULT_HUB = "https://api.botcord.chat";
@@ -84,7 +87,10 @@ Commands:
84
87
  route list
85
88
  route remove --room <rm_xxx>|--prefix <rm_xxx>
86
89
  config Print resolved config
87
- doctor [--json] [--bundle] [--full-log] Scan local runtimes (${ADAPTER_LIST});
90
+ doctor [--json] [--auth-check] [--bundle] [--full-log]
91
+ Scan local runtimes (${ADAPTER_LIST});
92
+ --auth-check also runs a Claude Code
93
+ ping probe and may contact Anthropic.
88
94
  --bundle also writes a zip under
89
95
  ~/.botcord/diagnostics/. Bundles
90
96
  daemon.log plus the latest 5 rotated
@@ -166,64 +172,6 @@ function parseArgs(argv) {
166
172
  }
167
173
  return { cmd: cmd ?? "", sub, flags, lists };
168
174
  }
169
- function readPid() {
170
- if (!existsSync(PID_PATH))
171
- return null;
172
- const raw = readFileSync(PID_PATH, "utf8").trim();
173
- const pid = Number(raw);
174
- return Number.isFinite(pid) && pid > 0 ? pid : null;
175
- }
176
- function pidAlive(pid) {
177
- try {
178
- process.kill(pid, 0);
179
- return true;
180
- }
181
- catch {
182
- return false;
183
- }
184
- }
185
- async function waitForPidExit(pid, timeoutMs) {
186
- const deadline = Date.now() + timeoutMs;
187
- while (Date.now() < deadline) {
188
- if (!pidAlive(pid))
189
- return true;
190
- await delay(100);
191
- }
192
- return !pidAlive(pid);
193
- }
194
- async function stopExistingDaemonForRestart(pid) {
195
- if (pid === process.pid)
196
- return;
197
- log.info("existing daemon found; restarting", { pid });
198
- try {
199
- process.kill(pid, "SIGTERM");
200
- }
201
- catch {
202
- try {
203
- unlinkSync(PID_PATH);
204
- }
205
- catch {
206
- // ignore
207
- }
208
- return;
209
- }
210
- if (!(await waitForPidExit(pid, 5_000))) {
211
- log.warn("existing daemon did not stop after SIGTERM; sending SIGKILL", { pid });
212
- try {
213
- process.kill(pid, "SIGKILL");
214
- }
215
- catch {
216
- // ignore
217
- }
218
- await waitForPidExit(pid, 2_000);
219
- }
220
- try {
221
- unlinkSync(PID_PATH);
222
- }
223
- catch {
224
- // ignore
225
- }
226
- }
227
175
  /**
228
176
  * Load the daemon config, auto-creating `~/.botcord/daemon/config.json`
229
177
  * with sensible defaults on first run. `--agent` (repeated) pins explicit
@@ -493,6 +441,15 @@ async function ensureUserAuthForStart(args) {
493
441
  return runDeviceCodeFlow({ hubUrl, label });
494
442
  }
495
443
  async function cmdStart(args) {
444
+ // Cloud-mode short-circuit: the Hub-managed E2B sandbox launches the
445
+ // daemon with `BOTCORD_CLOUD_DAEMON_ACCESS_TOKEN` set in the environment.
446
+ // In that case we skip the entire device-code / install-token / on-disk
447
+ // user-auth flow and dial `/cloud/daemon/ws` directly with the injected
448
+ // JWT. See ``packages/daemon/src/cloud-mode.ts`` + the design doc §4.
449
+ if (isCloudMode()) {
450
+ await cmdStartCloud(args);
451
+ return;
452
+ }
496
453
  let cfg = loadOrInitConfig(args);
497
454
  cfg = await refreshDiscoveredOpenclawGateways(cfg, "start");
498
455
  // Foreground is now the default. --background (alias -d) detaches.
@@ -511,14 +468,11 @@ async function cmdStart(args) {
511
468
  // var so we don't try to re-prompt for credentials it already has.
512
469
  if (process.env.BOTCORD_DAEMON_CHILD !== "1") {
513
470
  await ensureUserAuthForStart(args);
514
- const existing = readPid();
515
- if (existing && pidAlive(existing)) {
516
- await stopExistingDaemonForRestart(existing);
517
- }
471
+ await stopDaemonFromPidFileForRestart({ logger: log });
518
472
  }
519
473
  else {
520
- const existing = readPid();
521
- if (existing && existing !== process.pid && pidAlive(existing)) {
474
+ const existing = ensureNoOtherDaemonFromPidFile();
475
+ if (existing) {
522
476
  console.error(`daemon already running (pid ${existing})`);
523
477
  process.exit(1);
524
478
  }
@@ -551,17 +505,12 @@ async function cmdStart(args) {
551
505
  return;
552
506
  }
553
507
  // Foreground: we ARE the daemon.
554
- writeFileSync(PID_PATH, String(process.pid), { mode: 0o600 });
508
+ writeCurrentPid();
555
509
  const handle = await startDaemon({ config: cfg, configPath: CONFIG_FILE_PATH });
556
510
  const shutdown = async (sig) => {
557
511
  log.info("signal received", { sig });
558
512
  await handle.stop(sig);
559
- try {
560
- unlinkSync(PID_PATH);
561
- }
562
- catch {
563
- // ignore
564
- }
513
+ removePidFile();
565
514
  process.exit(0);
566
515
  };
567
516
  process.on("SIGTERM", () => shutdown("SIGTERM"));
@@ -572,6 +521,52 @@ async function cmdStart(args) {
572
521
  // Deliberately never resolves; `shutdown()` calls process.exit(0).
573
522
  });
574
523
  }
524
+ /**
525
+ * Cloud-mode start: launched by the Hub-managed E2B sandbox provider.
526
+ *
527
+ * No login flow and no on-disk credentials at boot. The daemon still uses
528
+ * the same PID-file singleton guard as local foreground starts because E2B
529
+ * resume hooks can run the startup command more than once in one sandbox.
530
+ *
531
+ * Always foreground — `--background` / `-d` is silently ignored because
532
+ * E2B sandboxes don't have a meaningful detach concept.
533
+ */
534
+ async function cmdStartCloud(_args) {
535
+ const cloudConfig = loadCloudModeConfig();
536
+ log.info("cmd start (cloud mode)", {
537
+ cloudDaemonInstanceId: cloudConfig.cloudDaemonInstanceId,
538
+ daemonInstanceId: cloudConfig.daemonInstanceId,
539
+ hubUrl: cloudConfig.hubUrl,
540
+ });
541
+ await stopDaemonFromPidFileForRestart({ logger: log });
542
+ writeCurrentPid();
543
+ // Cloud daemons always start with an empty in-memory config — every
544
+ // agent + route arrives over the control plane. We synthesize the
545
+ // shape `Gateway` expects without ever touching `~/.botcord/daemon/config.json`.
546
+ const cfg = {
547
+ defaultRoute: { adapter: "deepseek-tui", cwd: homedir() },
548
+ routes: [],
549
+ streamBlocks: true,
550
+ };
551
+ saveConfig(cfg);
552
+ log.info("cloud mode config initialized", { configPath: CONFIG_FILE_PATH });
553
+ const handle = await startCloudDaemon({
554
+ cloudConfig,
555
+ config: cfg,
556
+ configPath: CONFIG_FILE_PATH,
557
+ });
558
+ const shutdown = async (sig) => {
559
+ log.info("signal received", { sig });
560
+ await handle.stop(sig);
561
+ removePidFile();
562
+ process.exit(0);
563
+ };
564
+ process.on("SIGTERM", () => void shutdown("SIGTERM"));
565
+ process.on("SIGINT", () => void shutdown("SIGINT"));
566
+ await new Promise(() => {
567
+ // Deliberately never resolves; `shutdown()` calls `process.exit(0)`.
568
+ });
569
+ }
575
570
  async function cmdStop() {
576
571
  const pid = readPid();
577
572
  log.info("cmd stop", { pid });
@@ -581,12 +576,7 @@ async function cmdStop() {
581
576
  }
582
577
  if (!pidAlive(pid)) {
583
578
  console.error(`pid ${pid} not alive; removing stale pid file`);
584
- try {
585
- unlinkSync(PID_PATH);
586
- }
587
- catch {
588
- // ignore
589
- }
579
+ removePidFile();
590
580
  process.exit(1);
591
581
  }
592
582
  process.kill(pid, "SIGTERM");
@@ -1273,6 +1263,7 @@ async function cmdDoctor(args) {
1273
1263
  fileReader: fsFileReader,
1274
1264
  fetcher: defaultHttpFetcher,
1275
1265
  timeoutMs: 5_000,
1266
+ authCheck: args.flags["auth-check"] === true,
1276
1267
  });
1277
1268
  if (args.flags.json === true) {
1278
1269
  console.log(JSON.stringify(input, null, 2));
@@ -1,6 +1,7 @@
1
1
  import { BotCordClient, type AgentIdentitySnapshot, type ControlAck, type ControlFrame, type ListRuntimesResult, type RevokeAgentParams, type RevokeAgentResult } from "@botcord/protocol-core";
2
2
  import type { Gateway } from "./gateway/index.js";
3
3
  import type { PolicyResolverLike } from "./gateway/policy-resolver.js";
4
+ import type { GatewayRuntimeSnapshot } from "./gateway/index.js";
4
5
  import { type DaemonConfig } from "./config.js";
5
6
  import type { LoginSessionStore } from "./gateway/channels/login-session.js";
6
7
  /**
@@ -103,6 +104,7 @@ export declare function clearRuntimeProbeCache(): void;
103
104
  export declare function collectRuntimeSnapshot(opts?: {
104
105
  force?: boolean;
105
106
  }): ListRuntimesResult;
107
+ export declare function attachRuntimeHealth(snapshot: ListRuntimesResult, live: GatewayRuntimeSnapshot): ListRuntimesResult;
106
108
  /** Maximum number of `endpoints[]` entries persisted per runtime (RFC §3.8.2). */
107
109
  export declare const RUNTIME_ENDPOINTS_CAP = 32;
108
110
  /** Injection seam for L2 + L3 endpoint probes — kept testable + side-effect-free. */
package/dist/provision.js CHANGED
@@ -206,7 +206,7 @@ export function createProvisioner(opts) {
206
206
  catch {
207
207
  cfgForProbe = undefined;
208
208
  }
209
- const snapshot = await collectRuntimeSnapshotAsync({ cfg: cfgForProbe });
209
+ const snapshot = attachRuntimeHealth(await collectRuntimeSnapshotAsync({ cfg: cfgForProbe }), gateway.snapshot());
210
210
  daemonLog.debug("list_runtimes", { count: snapshot.runtimes.length });
211
211
  return { ok: true, result: snapshot };
212
212
  }
@@ -1462,6 +1462,44 @@ export function collectRuntimeSnapshot(opts = {}) {
1462
1462
  _runtimeProbeCache = { at: Date.now(), value };
1463
1463
  return value;
1464
1464
  }
1465
+ export function attachRuntimeHealth(snapshot, live) {
1466
+ const breakers = Object.values(live.runtimeCircuitBreakers ?? {});
1467
+ if (breakers.length === 0)
1468
+ return snapshot;
1469
+ const byRuntime = new Map();
1470
+ for (const breaker of breakers) {
1471
+ const list = byRuntime.get(breaker.runtime) ?? [];
1472
+ if (list.length < 32)
1473
+ list.push(breaker);
1474
+ byRuntime.set(breaker.runtime, list);
1475
+ }
1476
+ return {
1477
+ ...snapshot,
1478
+ runtimes: snapshot.runtimes.map((runtime) => {
1479
+ const runtimeBreakers = byRuntime.get(runtime.id);
1480
+ if (!runtimeBreakers?.length)
1481
+ return runtime;
1482
+ return {
1483
+ ...runtime,
1484
+ health: {
1485
+ ...(runtime.health ?? {}),
1486
+ circuitBreakers: runtimeBreakers.map((b) => ({
1487
+ key: b.key,
1488
+ channel: b.channel,
1489
+ accountId: b.accountId,
1490
+ conversationId: b.conversationId,
1491
+ threadId: b.threadId ?? null,
1492
+ failures: b.failures,
1493
+ openedAt: b.openedAt,
1494
+ blockedUntil: b.blockedUntil,
1495
+ lastFailureAt: b.lastFailureAt,
1496
+ lastError: b.lastError,
1497
+ })),
1498
+ },
1499
+ };
1500
+ }),
1501
+ };
1502
+ }
1465
1503
  /** Maximum number of `endpoints[]` entries persisted per runtime (RFC §3.8.2). */
1466
1504
  export const RUNTIME_ENDPOINTS_CAP = 32;
1467
1505
  export function classifyOpenclawAuthError(message) {
@@ -49,6 +49,21 @@ function renderTurns(snap, now) {
49
49
  }
50
50
  return out;
51
51
  }
52
+ function renderRuntimeCircuitBreakers(snap, now) {
53
+ const entries = Object.values(snap.runtimeCircuitBreakers ?? {});
54
+ if (entries.length === 0)
55
+ return ["Runtime circuit breakers:", " (none)"];
56
+ const out = ["Runtime circuit breakers:"];
57
+ const keyW = Math.max(3, ...entries.map((b) => b.key.length));
58
+ const rtW = Math.max(7, ...entries.map((b) => b.runtime.length));
59
+ const convW = Math.max(12, ...entries.map((b) => b.conversationId.length));
60
+ out.push(` ${pad("KEY", keyW)} ${pad("RUNTIME", rtW)} ${pad("CONVERSATION", convW)} FAILS BLOCKED FOR LAST ERROR`);
61
+ for (const b of entries) {
62
+ const blockedFor = relTime(b.blockedUntil - now).replace(" ago", "");
63
+ out.push(` ${pad(b.key, keyW)} ${pad(b.runtime, rtW)} ${pad(b.conversationId, convW)} ${pad(String(b.failures), 5)} ${pad(blockedFor, 11)} ${b.lastError}`);
64
+ }
65
+ return out;
66
+ }
52
67
  /**
53
68
  * Format a human-readable status block. Kept pure so it can be unit-tested
54
69
  * without touching disk or spawning a daemon.
@@ -89,6 +104,8 @@ export function renderStatus(input, now = Date.now()) {
89
104
  lines.push(...renderChannels(input.snapshot));
90
105
  lines.push("");
91
106
  lines.push(...renderTurns(input.snapshot, now));
107
+ lines.push("");
108
+ lines.push(...renderRuntimeCircuitBreakers(input.snapshot, now));
92
109
  }
93
110
  else if (input.alive) {
94
111
  lines.push("snapshot: unavailable (daemon running but no snapshot file found)");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botcord/daemon",
3
- "version": "0.2.75",
3
+ "version": "0.2.76",
4
4
  "description": "BotCord local daemon — bridges Hub inbox push to local Claude Code / Codex / Gemini CLIs",
5
5
  "type": "module",
6
6
  "bin": {
@@ -28,7 +28,7 @@
28
28
  },
29
29
  "dependencies": {
30
30
  "@botcord/cli": "^0.1.7",
31
- "@botcord/protocol-core": "^0.2.4",
31
+ "@botcord/protocol-core": "file:../protocol-core",
32
32
  "@larksuiteoapi/node-sdk": "^1.63.1",
33
33
  "ws": "^8.20.1"
34
34
  },
@@ -0,0 +1,42 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { CloudAuthManager, asUserAuthManager } from "../cloud-auth.js";
3
+ import type { CloudModeConfig } from "../cloud-mode.js";
4
+
5
+ function makeCfg(overrides: Partial<CloudModeConfig> = {}): CloudModeConfig {
6
+ return {
7
+ hubUrl: "https://api.botcord.chat",
8
+ cloudDaemonInstanceId: "cloud_dm_abc",
9
+ daemonInstanceId: "dm_abc",
10
+ accessToken: "tok_xyz",
11
+ ...overrides,
12
+ };
13
+ }
14
+
15
+ describe("CloudAuthManager", () => {
16
+ it("exposes a UserAuthRecord-shaped current property", () => {
17
+ const mgr = new CloudAuthManager(makeCfg());
18
+ expect(mgr.current.hubUrl).toBe("https://api.botcord.chat");
19
+ expect(mgr.current.daemonInstanceId).toBe("dm_abc");
20
+ expect(mgr.current.accessToken).toBe("tok_xyz");
21
+ expect(mgr.current.refreshToken).toBe("");
22
+ // expiresAt should be effectively infinity so the channel doesn't try to
23
+ // refresh — provider relaunches the daemon on token rotation.
24
+ expect(mgr.current.expiresAt).toBe(Number.MAX_SAFE_INTEGER);
25
+ });
26
+
27
+ it("uses the cloud daemon instance id as the userId surrogate", () => {
28
+ const mgr = new CloudAuthManager(makeCfg({ cloudDaemonInstanceId: "cloud_dm_xyz" }));
29
+ expect(mgr.current.userId).toBe("cloud_dm_xyz");
30
+ });
31
+
32
+ it("ensureAccessToken returns the injected token", async () => {
33
+ const mgr = new CloudAuthManager(makeCfg({ accessToken: "tok_42" }));
34
+ expect(await mgr.ensureAccessToken()).toBe("tok_42");
35
+ });
36
+
37
+ it("asUserAuthManager casts to the UserAuthManager shape without copying", () => {
38
+ const mgr = new CloudAuthManager(makeCfg());
39
+ const cast = asUserAuthManager(mgr);
40
+ expect(cast.current).toBe(mgr.current);
41
+ });
42
+ });