@botcord/daemon 0.2.74 → 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
@@ -188,6 +188,23 @@ function makeEnvelope(
188
188
  return { message: makeMessage(partial), ack };
189
189
  }
190
190
 
191
+ function cloudRunRaw(
192
+ budget: { max_wall_time_seconds?: number; max_tool_calls?: number },
193
+ ): Record<string, unknown> {
194
+ return {
195
+ envelope: {
196
+ type: "cloud_run",
197
+ payload: {
198
+ text: "run this",
199
+ cloud_run: {
200
+ run_id: "crun_test",
201
+ budget,
202
+ },
203
+ },
204
+ },
205
+ };
206
+ }
207
+
191
208
  function baseConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
192
209
  return {
193
210
  channels: [{ id: "botcord", type: "botcord", accountId: "ag_me" }],
@@ -225,6 +242,8 @@ describe("Dispatcher", () => {
225
242
  channel?: FakeChannel;
226
243
  runtimeFactory?: RuntimeFactory;
227
244
  turnTimeoutMs?: number;
245
+ runtimeAuthFailureThreshold?: number;
246
+ runtimeAuthFailureCooldownMs?: number;
228
247
  }) {
229
248
  const { store, dir } = await makeStore();
230
249
  tempDirs.push(dir);
@@ -237,6 +256,8 @@ describe("Dispatcher", () => {
237
256
  sessionStore: store,
238
257
  log: silentLogger(),
239
258
  turnTimeoutMs: args.turnTimeoutMs,
259
+ runtimeAuthFailureThreshold: args.runtimeAuthFailureThreshold,
260
+ runtimeAuthFailureCooldownMs: args.runtimeAuthFailureCooldownMs,
240
261
  });
241
262
  return { dispatcher, channel, store };
242
263
  }
@@ -293,6 +314,51 @@ describe("Dispatcher", () => {
293
314
  expect(store.all()[0].threadId).toBe("t_1");
294
315
  });
295
316
 
317
+ it("cloud_run: forwards budget caps to the runtime", async () => {
318
+ const runtime = new FakeRuntime({ reply: "ok", newSessionId: "sid-cloud" });
319
+ const { dispatcher } = await scaffold({
320
+ runtimeFactory: () => runtime,
321
+ });
322
+
323
+ await dispatcher.handle(
324
+ makeEnvelope({
325
+ id: "msg_cloud_budget",
326
+ raw: cloudRunRaw({ max_wall_time_seconds: 12, max_tool_calls: 7 }),
327
+ }),
328
+ );
329
+
330
+ expect(runtime.calls.length).toBe(1);
331
+ expect(runtime.calls[0].budget).toEqual({
332
+ maxWallTimeMs: 12000,
333
+ maxToolCalls: 7,
334
+ });
335
+ });
336
+
337
+ it("cloud_run: aborts the turn when tool-call budget is exceeded", async () => {
338
+ const runtime = new FakeRuntime({
339
+ reply: "should not deliver",
340
+ newSessionId: "sid-budget",
341
+ blocks: [
342
+ { raw: { type: "tool", name: "one" }, kind: "tool_use", seq: 1 },
343
+ { raw: { type: "tool", name: "two" }, kind: "tool_use", seq: 2 },
344
+ ],
345
+ });
346
+ const { dispatcher, channel, store } = await scaffold({
347
+ runtimeFactory: () => runtime,
348
+ });
349
+
350
+ await dispatcher.handle(
351
+ makeEnvelope({
352
+ id: "msg_cloud_tool_budget",
353
+ raw: cloudRunRaw({ max_tool_calls: 1 }),
354
+ }),
355
+ );
356
+
357
+ expect(channel.sends.length).toBe(1);
358
+ expect(channel.sends[0].message.text).toContain("Cloud run budget exceeded");
359
+ expect(store.all().length).toBe(0);
360
+ });
361
+
296
362
  it("sends replies to the provider reply id when it differs from the internal message id", async () => {
297
363
  const runtime = new FakeRuntime({ reply: "ok" });
298
364
  const feishuChannel = new FakeChannel({ id: "gw_feishu_1", type: "feishu" });
@@ -394,6 +460,60 @@ describe("Dispatcher", () => {
394
460
  expect(channel.sends[0].message.type).toBe("error");
395
461
  });
396
462
 
463
+ it("treats auth failure text as an error and does not persist the failed session", async () => {
464
+ let callNo = 0;
465
+ const runtimeFactory: RuntimeFactory = () => {
466
+ callNo += 1;
467
+ if (callNo === 1) return new FakeRuntime({ reply: "ok", newSessionId: "sid-1" });
468
+ return new FakeRuntime({
469
+ reply: "Failed to authenticate. API Error: 403 Request not allowed",
470
+ newSessionId: "sid-bad",
471
+ });
472
+ };
473
+ const { dispatcher, store, channel } = await scaffold({ runtimeFactory });
474
+
475
+ await dispatcher.handle(
476
+ makeEnvelope({ id: "msg_1", conversation: { id: "rm_oc_auth", kind: "direct" } }),
477
+ );
478
+ expect(store.all()[0].runtimeSessionId).toBe("sid-1");
479
+
480
+ await dispatcher.handle(
481
+ makeEnvelope({ id: "msg_2", conversation: { id: "rm_oc_auth", kind: "direct" } }),
482
+ );
483
+
484
+ expect(store.all().length).toBe(0);
485
+ expect(channel.sends).toHaveLength(2);
486
+ expect(channel.sends[1].message.type).toBe("error");
487
+ expect(channel.sends[1].message.text).toContain("Runtime error");
488
+ expect(channel.sends[1].message.text).toContain("Failed to authenticate");
489
+ });
490
+
491
+ it("opens an auth circuit breaker after repeated failures and skips runtime spawn", async () => {
492
+ const runtime = new FakeRuntime({
493
+ reply: "Failed to authenticate. API Error: 403 Request not allowed",
494
+ newSessionId: "sid-bad",
495
+ });
496
+ const { dispatcher, channel, store } = await scaffold({
497
+ runtimeFactory: () => runtime,
498
+ runtimeAuthFailureThreshold: 2,
499
+ runtimeAuthFailureCooldownMs: 60_000,
500
+ });
501
+ const conversation = { id: "rm_oc_auth_breaker", kind: "direct" as const };
502
+
503
+ await dispatcher.handle(makeEnvelope({ id: "msg_1", conversation }));
504
+ await dispatcher.handle(makeEnvelope({ id: "msg_2", conversation }));
505
+ expect(runtime.calls).toHaveLength(2);
506
+ expect(Object.values(dispatcher.runtimeCircuitBreakers())).toHaveLength(1);
507
+
508
+ await dispatcher.handle(makeEnvelope({ id: "msg_3", conversation }));
509
+
510
+ expect(runtime.calls).toHaveLength(2);
511
+ expect(store.all()).toHaveLength(0);
512
+ expect(channel.sends).toHaveLength(3);
513
+ expect(channel.sends[2].message.type).toBe("error");
514
+ expect(channel.sends[2].message.text).toContain("dispatch paused");
515
+ });
516
+
397
517
  it("applies composeUserTurn before handing text to the runtime", async () => {
398
518
  const runtime = new FakeRuntime({ reply: "ok", newSessionId: "sid-1" });
399
519
  const { store, dir } = await makeStore();
@@ -43,7 +43,8 @@ type InboxDrainTrigger =
43
43
  | "ws_auth_ok"
44
44
  | "ws_inbox_update"
45
45
  | "coalesced_inbox_update"
46
- | "has_more_continue";
46
+ | "has_more_continue"
47
+ | "poll_interval";
47
48
 
48
49
  /** Minimal surface the adapter needs from `BotCordClient`. Matches the subset used at runtime. */
49
50
  export interface BotCordChannelClient {
@@ -90,7 +91,7 @@ export interface BotCordChannelOptions {
90
91
  credentialsPath?: string;
91
92
  /** Override the Hub base URL. Defaults to the `hubUrl` stored in credentials. */
92
93
  hubBaseUrl?: string;
93
- /** Not used by the WS-only loop today; kept for future polling fallback. */
94
+ /** Periodic inbox polling fallback. Set to 0 to disable. Defaults to 30s. */
94
95
  pollIntervalMs?: number;
95
96
  /** Test hook: supply a pre-built client instead of loading credentials from disk. */
96
97
  client?: BotCordChannelClient;
@@ -153,6 +154,9 @@ function defaultClientFactory(input: {
153
154
  function isOwnerTrust(msg: InboxMessage): boolean {
154
155
  if (msg.room_id?.startsWith(OWNER_CHAT_PREFIX)) return true;
155
156
  if (msg.source_type === "dashboard_user_chat") return true;
157
+ // Cloud Agent run tasks are Hub-issued on the user's behalf, same
158
+ // trust posture as owner chat.
159
+ if (msg.source_type === "cloud_agent_run") return true;
156
160
  return false;
157
161
  }
158
162
 
@@ -186,11 +190,19 @@ function normalizeInbox(
186
190
  if (!env) return null;
187
191
  // `message` is the normal conversational envelope; `contact_request` is
188
192
  // a lightweight inbound asking the agent to notify its owner (the
189
- // composer appends the notify-owner hint). All other envelope types
190
- // (notification, system, contact_added/removed, …) are still filtered
191
- // out here they belong in a separate push-notification path that
192
- // daemon does not yet implement.
193
- if (env.type !== "message" && env.type !== "contact_request") return null;
193
+ // composer appends the notify-owner hint); `cloud_run` carries a
194
+ // Cloud Agent run task with embedded run_id + budget (the cloud
195
+ // daemon's runtime adapter reads them from `raw.envelope.payload.cloud_run`
196
+ // and reports usage back via /internal/cloud-agents/.../settle when the
197
+ // run completes). All other envelope types (notification, system,
198
+ // contact_added/removed, …) are still filtered out — they belong in
199
+ // a separate push-notification path that daemon does not yet implement.
200
+ if (
201
+ env.type !== "message" &&
202
+ env.type !== "contact_request" &&
203
+ env.type !== "cloud_run"
204
+ )
205
+ return null;
194
206
  if (!msg.room_id) return null;
195
207
 
196
208
  const rawText =
@@ -482,6 +494,7 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
482
494
  let ws: WebSocket | null = null;
483
495
  let reconnectTimer: NodeJS.Timeout | null = null;
484
496
  let keepaliveTimer: NodeJS.Timeout | null = null;
497
+ let pollTimer: NodeJS.Timeout | null = null;
485
498
  let reconnectAttempt = 0;
486
499
  let connectionSeq = 0;
487
500
  let consecutiveAuthFailures = 0;
@@ -507,6 +520,10 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
507
520
  clearInterval(keepaliveTimer);
508
521
  keepaliveTimer = null;
509
522
  }
523
+ if (pollTimer) {
524
+ clearInterval(pollTimer);
525
+ pollTimer = null;
526
+ }
510
527
  }
511
528
 
512
529
  function markStatus(patch: Partial<ChannelStatusSnapshot>) {
@@ -709,6 +726,16 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
709
726
  });
710
727
  log.info("botcord ws authenticated", { agentId: msg.agent_id });
711
728
  void fireInbox("ws_auth_ok");
729
+ const pollIntervalMs = options.pollIntervalMs ?? 30_000;
730
+ if (pollTimer) clearInterval(pollTimer);
731
+ if (pollIntervalMs > 0) {
732
+ pollTimer = setInterval(() => {
733
+ if (ws === socket && socket.readyState === WebSocket.OPEN) {
734
+ void fireInbox("poll_interval");
735
+ }
736
+ }, pollIntervalMs);
737
+ pollTimer.unref?.();
738
+ }
712
739
  if (keepaliveTimer) clearInterval(keepaliveTimer);
713
740
  keepaliveTimer = setInterval(() => {
714
741
  if (ws === socket && socket.readyState === WebSocket.OPEN) {
@@ -1042,6 +1069,14 @@ function normalizeBlockForHub(
1042
1069
  }
1043
1070
 
1044
1071
  // "other" — e.g. Claude Code `type:"result"` end-of-turn summary.
1072
+ if (isTerminalRuntimeBlock(raw)) {
1073
+ payload.terminal = true;
1074
+ payload.details = formatBlockDetails(raw);
1075
+ const event = typeof raw?.event === "string" ? raw.event : undefined;
1076
+ const embedded = typeof raw?.payload?.event === "string" ? raw.payload.event : undefined;
1077
+ if (event || embedded) payload.event = event ?? embedded;
1078
+ return { kind: "other", seq, payload };
1079
+ }
1045
1080
  if (raw?.type === "result") {
1046
1081
  if (typeof raw.result === "string") payload.text = raw.result;
1047
1082
  if (typeof raw.subtype === "string") payload.subtype = raw.subtype;
@@ -1050,6 +1085,18 @@ function normalizeBlockForHub(
1050
1085
  return { kind: "other", seq, payload };
1051
1086
  }
1052
1087
 
1088
+ function isTerminalRuntimeBlock(raw: any): boolean {
1089
+ const event = typeof raw?.event === "string" ? raw.event : undefined;
1090
+ const embedded = typeof raw?.payload?.event === "string" ? raw.payload.event : undefined;
1091
+ const terminal = event ?? embedded;
1092
+ return (
1093
+ terminal === "turn.completed" ||
1094
+ terminal === "turn.finished" ||
1095
+ terminal === "turn.done" ||
1096
+ terminal === "done"
1097
+ );
1098
+ }
1099
+
1053
1100
  function formatBlockDetails(raw: unknown): string {
1054
1101
  if (!raw || typeof raw !== "object") return "";
1055
1102
  const r = raw as any;