@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
@@ -0,0 +1,26 @@
1
+ export interface SingletonLogger {
2
+ info(message: string, meta?: Record<string, unknown>): void;
3
+ warn(message: string, meta?: Record<string, unknown>): void;
4
+ }
5
+ export declare function readPid(pidPath?: string): number | null;
6
+ export declare function pidAlive(pid: number): boolean;
7
+ export declare function waitForPidExit(pid: number, timeoutMs: number): Promise<boolean>;
8
+ export declare function stopExistingDaemonForRestart(pid: number, opts?: {
9
+ pidPath?: string;
10
+ currentPid?: number;
11
+ logger?: SingletonLogger;
12
+ }): Promise<void>;
13
+ export declare function stopDaemonFromPidFileForRestart(opts?: {
14
+ pidPath?: string;
15
+ currentPid?: number;
16
+ logger?: SingletonLogger;
17
+ }): Promise<void>;
18
+ export declare function ensureNoOtherDaemonFromPidFile(opts?: {
19
+ pidPath?: string;
20
+ currentPid?: number;
21
+ }): number | null;
22
+ export declare function writeCurrentPid(opts?: {
23
+ pidPath?: string;
24
+ currentPid?: number;
25
+ }): void;
26
+ export declare function removePidFile(pidPath?: string): void;
@@ -0,0 +1,91 @@
1
+ import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { PID_PATH } from "./config.js";
3
+ const noopLogger = {
4
+ info() {
5
+ // noop
6
+ },
7
+ warn() {
8
+ // noop
9
+ },
10
+ };
11
+ export function readPid(pidPath = PID_PATH) {
12
+ if (!existsSync(pidPath))
13
+ return null;
14
+ const raw = readFileSync(pidPath, "utf8").trim();
15
+ const pid = Number(raw);
16
+ return Number.isFinite(pid) && pid > 0 ? pid : null;
17
+ }
18
+ export function pidAlive(pid) {
19
+ try {
20
+ process.kill(pid, 0);
21
+ return true;
22
+ }
23
+ catch {
24
+ return false;
25
+ }
26
+ }
27
+ export async function waitForPidExit(pid, timeoutMs) {
28
+ const deadline = Date.now() + timeoutMs;
29
+ while (Date.now() < deadline) {
30
+ if (!pidAlive(pid))
31
+ return true;
32
+ await delay(100);
33
+ }
34
+ return !pidAlive(pid);
35
+ }
36
+ export async function stopExistingDaemonForRestart(pid, opts = {}) {
37
+ const pidPath = opts.pidPath ?? PID_PATH;
38
+ const currentPid = opts.currentPid ?? process.pid;
39
+ const logger = opts.logger ?? noopLogger;
40
+ if (pid === currentPid)
41
+ return;
42
+ logger.info("existing daemon found; restarting", { pid });
43
+ try {
44
+ process.kill(pid, "SIGTERM");
45
+ }
46
+ catch {
47
+ removePidFile(pidPath);
48
+ return;
49
+ }
50
+ if (!(await waitForPidExit(pid, 5_000))) {
51
+ logger.warn("existing daemon did not stop after SIGTERM; sending SIGKILL", { pid });
52
+ try {
53
+ process.kill(pid, "SIGKILL");
54
+ }
55
+ catch {
56
+ // ignore
57
+ }
58
+ await waitForPidExit(pid, 2_000);
59
+ }
60
+ removePidFile(pidPath);
61
+ }
62
+ export async function stopDaemonFromPidFileForRestart(opts = {}) {
63
+ const pidPath = opts.pidPath ?? PID_PATH;
64
+ const existing = readPid(pidPath);
65
+ if (existing && pidAlive(existing)) {
66
+ await stopExistingDaemonForRestart(existing, opts);
67
+ }
68
+ }
69
+ export function ensureNoOtherDaemonFromPidFile(opts = {}) {
70
+ const pidPath = opts.pidPath ?? PID_PATH;
71
+ const currentPid = opts.currentPid ?? process.pid;
72
+ const existing = readPid(pidPath);
73
+ if (existing && existing !== currentPid && pidAlive(existing)) {
74
+ return existing;
75
+ }
76
+ return null;
77
+ }
78
+ export function writeCurrentPid(opts = {}) {
79
+ writeFileSync(opts.pidPath ?? PID_PATH, String(opts.currentPid ?? process.pid), { mode: 0o600 });
80
+ }
81
+ export function removePidFile(pidPath = PID_PATH) {
82
+ try {
83
+ unlinkSync(pidPath);
84
+ }
85
+ catch {
86
+ // ignore
87
+ }
88
+ }
89
+ function delay(ms) {
90
+ return new Promise((resolve) => setTimeout(resolve, ms));
91
+ }
package/dist/daemon.d.ts CHANGED
@@ -64,7 +64,7 @@ export interface RuntimeSnapshotSink {
64
64
  * failure is non-fatal (the Hub will re-query via `list_runtimes` on demand
65
65
  * or wait for the next daemon restart). Exported for unit tests.
66
66
  */
67
- export declare function pushRuntimeSnapshot(sink: RuntimeSnapshotSink): boolean;
67
+ export declare function pushRuntimeSnapshot(sink: RuntimeSnapshotSink, liveSnapshot?: GatewayRuntimeSnapshot): boolean;
68
68
  /** Options accepted by {@link startDaemon} — the P0.5 compatibility shim. */
69
69
  export interface DaemonRuntimeOptions {
70
70
  config: DaemonConfig;
package/dist/daemon.js CHANGED
@@ -7,7 +7,7 @@ import { ensureAgentWorkspace } from "./agent-workspace.js";
7
7
  import { ControlChannel } from "./control-channel.js";
8
8
  import { toGatewayConfig } from "./daemon-config-map.js";
9
9
  import { log as daemonLog } from "./log.js";
10
- import { adoptDiscoveredOpenclawAgents, collectRuntimeSnapshot, createProvisioner, } from "./provision.js";
10
+ import { adoptDiscoveredOpenclawAgents, attachRuntimeHealth, collectRuntimeSnapshot, createProvisioner, } from "./provision.js";
11
11
  import { openclawAutoProvisionEnabled } from "./openclaw-discovery.js";
12
12
  import { SnapshotWriter } from "./snapshot-writer.js";
13
13
  import { createDaemonSystemContextBuilder } from "./system-context.js";
@@ -146,8 +146,9 @@ export function createDaemonChannel(chCfg, deps) {
146
146
  * failure is non-fatal (the Hub will re-query via `list_runtimes` on demand
147
147
  * or wait for the next daemon restart). Exported for unit tests.
148
148
  */
149
- export function pushRuntimeSnapshot(sink) {
150
- const snap = collectRuntimeSnapshot();
149
+ export function pushRuntimeSnapshot(sink, liveSnapshot) {
150
+ const base = collectRuntimeSnapshot();
151
+ const snap = liveSnapshot ? attachRuntimeHealth(base, liveSnapshot) : base;
151
152
  const ok = sink.send({
152
153
  id: `rt_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
153
154
  type: CONTROL_FRAME_TYPES.RUNTIME_SNAPSHOT,
@@ -353,7 +354,15 @@ export async function startDaemon(opts) {
353
354
  }));
354
355
  }
355
356
  };
356
- const gateway = new Gateway({
357
+ let controlChannel = null;
358
+ let gateway;
359
+ const pushLiveRuntimeSnapshot = () => {
360
+ if (!controlChannel)
361
+ return;
362
+ const pushed = pushRuntimeSnapshot(controlChannel, gateway.snapshot());
363
+ logger.info("control-channel: live runtime_snapshot push", { ok: pushed });
364
+ };
365
+ gateway = new Gateway({
357
366
  config: gwConfig,
358
367
  sessionStorePath: opts.sessionStorePath ?? SESSIONS_PATH,
359
368
  createChannel: (chCfg) => createDaemonChannel(chCfg, {
@@ -367,6 +376,7 @@ export async function startDaemon(opts) {
367
376
  buildMemoryContext,
368
377
  onInbound,
369
378
  onOutbound,
379
+ onRuntimeCircuitBreakerChange: pushLiveRuntimeSnapshot,
370
380
  composeUserTurn: composeBotCordUserTurn,
371
381
  attentionGate,
372
382
  resolveHubUrl,
@@ -416,7 +426,6 @@ export async function startDaemon(opts) {
416
426
  // Control channel is optional — daemon still runs (data-plane only)
417
427
  // when user-auth hasn't been set up yet. Operators can `login` later
418
428
  // without restarting, but for P0 we require a restart to pick it up.
419
- let controlChannel = null;
420
429
  if (userAuth?.current && !opts.disableControlChannel) {
421
430
  logger.info("control-channel: enabling", {
422
431
  userId: userAuth.current.userId,
@@ -455,7 +464,7 @@ export async function startDaemon(opts) {
455
464
  // Plan §8.5 P0 — push one runtime snapshot immediately after connect
456
465
  // so Hub's `daemon_instances.runtimes_json` is populated for the
457
466
  // dashboard even before any user action. No periodic refresh in P0.
458
- const pushed = pushRuntimeSnapshot(controlChannel);
467
+ const pushed = pushRuntimeSnapshot(controlChannel, gateway.snapshot());
459
468
  logger.info("control-channel: initial runtime_snapshot push", {
460
469
  ok: pushed,
461
470
  });
package/dist/doctor.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { RuntimeProbeEntry } from "./adapters/runtimes.js";
2
2
  import type { DaemonConfig } from "./config.js";
3
+ import { type ClaudeAuthProbeResult } from "./gateway/runtimes/claude-code.js";
3
4
  /** Summary of a single channel's readiness, printable by the doctor command. */
4
5
  export interface ChannelProbeResult {
5
6
  id: string;
@@ -50,6 +51,7 @@ export interface DoctorRuntimeEndpoint {
50
51
  /** Augmented runtime entry that may carry endpoint probe results. */
51
52
  export interface DoctorRuntimeEntry extends RuntimeProbeEntry {
52
53
  endpoints?: DoctorRuntimeEndpoint[];
54
+ auth?: ClaudeAuthProbeResult;
53
55
  }
54
56
  /** Input for the rendered doctor output. */
55
57
  export interface DoctorInput {
@@ -107,9 +109,10 @@ export declare function renderDoctor(input: DoctorInput): string;
107
109
  * Thin orchestrator: runs runtime + channel probes and returns the rendered
108
110
  * text. Keeps `index.ts` free of probe wiring.
109
111
  */
110
- export declare function runDoctor(runtimes: RuntimeProbeEntry[], channels: ChannelProbeConfig[], opts: {
112
+ export declare function runDoctor(runtimes: DoctorRuntimeEntry[], channels: ChannelProbeConfig[], opts: {
111
113
  credentialsPath: (accountId: string) => string;
112
114
  fileReader: DoctorFileReader;
113
115
  fetcher: DoctorHttpFetcher;
114
116
  timeoutMs?: number;
117
+ authCheck?: boolean;
115
118
  }): Promise<DoctorInput>;
package/dist/doctor.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { resolveBootAgents } from "./agent-discovery.js";
2
+ import { probeClaudeAuth } from "./gateway/runtimes/claude-code.js";
2
3
  /**
3
4
  * Build the implicit channel list for a daemon config. One channel per
4
5
  * configured or discovered agent, keyed by agentId (matches
@@ -74,12 +75,11 @@ export async function probeChannel(ch, opts) {
74
75
  result.hubMessage = "skipped (no hub URL)";
75
76
  return result;
76
77
  }
77
- // Probe `/` — the hub is ASGI and responds 2xx/3xx/404 which is fine for
78
- // "reachable". We treat any response as reachable; network errors fall
79
- // through to hubOk=false.
78
+ // Probe `/` — the hub is ASGI and may answer 404 when the host is still
79
+ // reachable. Network errors fall through to hubOk=false.
80
80
  const probeUrl = `${result.hubUrl.replace(/\/+$/, "")}/`;
81
81
  const http = await opts.fetcher(probeUrl, opts.timeoutMs);
82
- if (http.ok) {
82
+ if (http.ok || http.status === 404) {
83
83
  result.hubOk = true;
84
84
  result.hubMessage = `reachable (HTTP ${http.status})`;
85
85
  }
@@ -159,6 +159,10 @@ export function renderDoctor(input) {
159
159
  if (!e.result.available && e.installHint) {
160
160
  lines.push(` → ${e.installHint}`);
161
161
  }
162
+ if (e.auth) {
163
+ const authStatus = e.auth.checked ? (e.auth.ok ? "ok" : "failed") : "skipped";
164
+ lines.push(` auth ${authStatus}: ${e.auth.message}`);
165
+ }
162
166
  if (e.endpoints && e.endpoints.length > 0) {
163
167
  for (const ep of e.endpoints) {
164
168
  const mark = ep.reachable ? "✓" : "✗";
@@ -204,6 +208,13 @@ export function renderDoctor(input) {
204
208
  * text. Keeps `index.ts` free of probe wiring.
205
209
  */
206
210
  export async function runDoctor(runtimes, channels, opts) {
211
+ if (opts.authCheck) {
212
+ for (const runtime of runtimes) {
213
+ if (runtime.id === "claude-code" && runtime.result.available) {
214
+ runtime.auth = probeClaudeAuth();
215
+ }
216
+ }
217
+ }
207
218
  const channelResults = await probeChannels({
208
219
  channels,
209
220
  credentialsPath: opts.credentialsPath,
@@ -51,7 +51,7 @@ export interface BotCordChannelOptions {
51
51
  credentialsPath?: string;
52
52
  /** Override the Hub base URL. Defaults to the `hubUrl` stored in credentials. */
53
53
  hubBaseUrl?: string;
54
- /** Not used by the WS-only loop today; kept for future polling fallback. */
54
+ /** Periodic inbox polling fallback. Set to 0 to disable. Defaults to 30s. */
55
55
  pollIntervalMs?: number;
56
56
  /** Test hook: supply a pre-built client instead of loading credentials from disk. */
57
57
  client?: BotCordChannelClient;
@@ -59,6 +59,10 @@ function isOwnerTrust(msg) {
59
59
  return true;
60
60
  if (msg.source_type === "dashboard_user_chat")
61
61
  return true;
62
+ // Cloud Agent run tasks are Hub-issued on the user's behalf, same
63
+ // trust posture as owner chat.
64
+ if (msg.source_type === "cloud_agent_run")
65
+ return true;
62
66
  return false;
63
67
  }
64
68
  /**
@@ -89,11 +93,16 @@ function normalizeInbox(msg, options) {
89
93
  return null;
90
94
  // `message` is the normal conversational envelope; `contact_request` is
91
95
  // a lightweight inbound asking the agent to notify its owner (the
92
- // composer appends the notify-owner hint). All other envelope types
93
- // (notification, system, contact_added/removed, …) are still filtered
94
- // out here they belong in a separate push-notification path that
95
- // daemon does not yet implement.
96
- if (env.type !== "message" && env.type !== "contact_request")
96
+ // composer appends the notify-owner hint); `cloud_run` carries a
97
+ // Cloud Agent run task with embedded run_id + budget (the cloud
98
+ // daemon's runtime adapter reads them from `raw.envelope.payload.cloud_run`
99
+ // and reports usage back via /internal/cloud-agents/.../settle when the
100
+ // run completes). All other envelope types (notification, system,
101
+ // contact_added/removed, …) are still filtered out — they belong in
102
+ // a separate push-notification path that daemon does not yet implement.
103
+ if (env.type !== "message" &&
104
+ env.type !== "contact_request" &&
105
+ env.type !== "cloud_run")
97
106
  return null;
98
107
  if (!msg.room_id)
99
108
  return null;
@@ -348,6 +357,7 @@ export function createBotCordChannel(options) {
348
357
  let ws = null;
349
358
  let reconnectTimer = null;
350
359
  let keepaliveTimer = null;
360
+ let pollTimer = null;
351
361
  let reconnectAttempt = 0;
352
362
  let connectionSeq = 0;
353
363
  let consecutiveAuthFailures = 0;
@@ -371,6 +381,10 @@ export function createBotCordChannel(options) {
371
381
  clearInterval(keepaliveTimer);
372
382
  keepaliveTimer = null;
373
383
  }
384
+ if (pollTimer) {
385
+ clearInterval(pollTimer);
386
+ pollTimer = null;
387
+ }
374
388
  }
375
389
  function markStatus(patch) {
376
390
  statusSnapshot = { ...statusSnapshot, ...patch };
@@ -574,6 +588,17 @@ export function createBotCordChannel(options) {
574
588
  });
575
589
  log.info("botcord ws authenticated", { agentId: msg.agent_id });
576
590
  void fireInbox("ws_auth_ok");
591
+ const pollIntervalMs = options.pollIntervalMs ?? 30_000;
592
+ if (pollTimer)
593
+ clearInterval(pollTimer);
594
+ if (pollIntervalMs > 0) {
595
+ pollTimer = setInterval(() => {
596
+ if (ws === socket && socket.readyState === WebSocket.OPEN) {
597
+ void fireInbox("poll_interval");
598
+ }
599
+ }, pollIntervalMs);
600
+ pollTimer.unref?.();
601
+ }
577
602
  if (keepaliveTimer)
578
603
  clearInterval(keepaliveTimer);
579
604
  keepaliveTimer = setInterval(() => {
@@ -912,6 +937,15 @@ function normalizeBlockForHub(block, seq) {
912
937
  return { kind: "thinking", seq, payload };
913
938
  }
914
939
  // "other" — e.g. Claude Code `type:"result"` end-of-turn summary.
940
+ if (isTerminalRuntimeBlock(raw)) {
941
+ payload.terminal = true;
942
+ payload.details = formatBlockDetails(raw);
943
+ const event = typeof raw?.event === "string" ? raw.event : undefined;
944
+ const embedded = typeof raw?.payload?.event === "string" ? raw.payload.event : undefined;
945
+ if (event || embedded)
946
+ payload.event = event ?? embedded;
947
+ return { kind: "other", seq, payload };
948
+ }
915
949
  if (raw?.type === "result") {
916
950
  if (typeof raw.result === "string")
917
951
  payload.text = raw.result;
@@ -922,6 +956,15 @@ function normalizeBlockForHub(block, seq) {
922
956
  }
923
957
  return { kind: "other", seq, payload };
924
958
  }
959
+ function isTerminalRuntimeBlock(raw) {
960
+ const event = typeof raw?.event === "string" ? raw.event : undefined;
961
+ const embedded = typeof raw?.payload?.event === "string" ? raw.payload.event : undefined;
962
+ const terminal = event ?? embedded;
963
+ return (terminal === "turn.completed" ||
964
+ terminal === "turn.finished" ||
965
+ terminal === "turn.done" ||
966
+ terminal === "done");
967
+ }
925
968
  function formatBlockDetails(raw) {
926
969
  if (!raw || typeof raw !== "object")
927
970
  return "";
@@ -1,7 +1,7 @@
1
1
  import type { GatewayLogger } from "./log.js";
2
2
  import { type SessionStore } from "./session-store.js";
3
3
  import { type TranscriptWriter } from "./transcript.js";
4
- import type { ChannelAdapter, GatewayConfig, GatewayInboundEnvelope, GatewayInboundMessage, GatewayRoute, InboundObserver, MemoryContextBuilder, OutboundObserver, RuntimeAdapter, SystemContextBuilder, TurnStatusSnapshot, UserTurnBuilder } from "./types.js";
4
+ import type { ChannelAdapter, GatewayConfig, GatewayInboundEnvelope, GatewayInboundMessage, GatewayRoute, InboundObserver, MemoryContextBuilder, OutboundObserver, RuntimeAdapter, RuntimeRunResult, RuntimeCircuitBreakerSnapshot, SystemContextBuilder, TurnStatusSnapshot, UserTurnBuilder } from "./types.js";
5
5
  /** Factory signature for building a runtime adapter at turn dispatch time. */
6
6
  export type RuntimeFactory = (runtimeId: string, extraArgs?: string[]) => RuntimeAdapter;
7
7
  /** Constructor options for `Dispatcher`. */
@@ -12,6 +12,8 @@ export interface DispatcherOptions {
12
12
  sessionStore: SessionStore;
13
13
  log: GatewayLogger;
14
14
  turnTimeoutMs?: number;
15
+ runtimeAuthFailureThreshold?: number;
16
+ runtimeAuthFailureCooldownMs?: number;
15
17
  /**
16
18
  * Live reference to the Gateway's managed-route map. Dispatcher reads
17
19
  * `values()` on every `resolveRoute` call so hot-add/remove take effect
@@ -50,6 +52,24 @@ export interface DispatcherOptions {
50
52
  * and suppressed so observer failures never break the turn.
51
53
  */
52
54
  onOutbound?: OutboundObserver;
55
+ onRuntimeCircuitBreakerChange?: () => void;
56
+ /**
57
+ * Optional observer fired exactly once per turn after ``runtime.run``
58
+ * resolves (or throws / times out). Receives the inbound message, the
59
+ * raw runtime result (may be undefined on throw), the elapsed wall
60
+ * time in milliseconds, and any thrown error. The cloud daemon hooks
61
+ * this to settle ``cloud_run`` envelopes against the Hub's usage
62
+ * ledger; local daemons leave it unset.
63
+ *
64
+ * Errors thrown by the observer are logged and swallowed — settle
65
+ * failures must never break the agent reply path.
66
+ */
67
+ onTurnComplete?: (event: {
68
+ message: GatewayInboundMessage;
69
+ result?: RuntimeRunResult;
70
+ wallTimeMs: number;
71
+ error?: unknown;
72
+ }) => Promise<void> | void;
53
73
  /**
54
74
  * Optional attention gate (PR3, design §4.2). Resolved AFTER `onInbound`
55
75
  * runs and BEFORE the runtime turn enqueues, so working memory / activity
@@ -92,10 +112,14 @@ export declare class Dispatcher {
92
112
  private readonly sessionStore;
93
113
  private readonly log;
94
114
  private readonly turnTimeoutMs;
115
+ private readonly runtimeAuthFailureThreshold;
116
+ private readonly runtimeAuthFailureCooldownMs;
95
117
  private readonly buildSystemContext?;
96
118
  private readonly buildMemoryContext?;
97
119
  private readonly onInbound?;
98
120
  private readonly onOutbound?;
121
+ private readonly onTurnComplete?;
122
+ private readonly onRuntimeCircuitBreakerChange?;
99
123
  private readonly composeUserTurn?;
100
124
  private readonly managedRoutes?;
101
125
  private readonly attentionGate?;
@@ -103,6 +127,7 @@ export declare class Dispatcher {
103
127
  private readonly transcript;
104
128
  private readonly queues;
105
129
  private readonly deferredMultimodal;
130
+ private readonly runtimeAuthFailures;
106
131
  /**
107
132
  * Last `/hub/typing` ping timestamp per (accountId, conversationId).
108
133
  * Used to debounce cancel-previous bursts so we don't trip Hub's 20/min
@@ -114,10 +139,18 @@ export declare class Dispatcher {
114
139
  handle(envelope: GatewayInboundEnvelope): Promise<void>;
115
140
  /** Snapshot of currently running turns keyed by queue key. */
116
141
  turns(): Record<string, TurnStatusSnapshot>;
142
+ runtimeCircuitBreakers(): Record<string, RuntimeCircuitBreakerSnapshot>;
117
143
  private safeAck;
118
144
  private getQueue;
119
145
  private deferMultimodal;
120
146
  private takeDeferredMultimodal;
147
+ private runtimeAuthBreakerKey;
148
+ private openRuntimeAuthBreaker;
149
+ private pruneExpiredRuntimeAuthBreakers;
150
+ private recordRuntimeAuthFailure;
151
+ private clearRuntimeAuthFailures;
152
+ private notifyRuntimeCircuitBreakerChange;
153
+ private skipRuntimeForAuthBreaker;
121
154
  private runCancelPrevious;
122
155
  /**
123
156
  * Serial mode with coalesce-on-drain semantics: