@botcord/daemon 0.2.4 → 0.2.6

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 (93) hide show
  1. package/dist/agent-discovery.d.ts +7 -3
  2. package/dist/agent-discovery.js +9 -1
  3. package/dist/agent-workspace.d.ts +62 -0
  4. package/dist/agent-workspace.js +140 -10
  5. package/dist/config.d.ts +49 -1
  6. package/dist/config.js +57 -1
  7. package/dist/control-channel.d.ts +1 -4
  8. package/dist/control-channel.js +1 -4
  9. package/dist/daemon-config-map.d.ts +29 -12
  10. package/dist/daemon-config-map.js +105 -8
  11. package/dist/daemon.d.ts +2 -0
  12. package/dist/daemon.js +52 -5
  13. package/dist/doctor.d.ts +27 -1
  14. package/dist/doctor.js +22 -1
  15. package/dist/gateway/cli-resolver.d.ts +34 -0
  16. package/dist/gateway/cli-resolver.js +74 -0
  17. package/dist/gateway/dispatcher.d.ts +66 -1
  18. package/dist/gateway/dispatcher.js +583 -56
  19. package/dist/gateway/gateway.d.ts +29 -1
  20. package/dist/gateway/gateway.js +10 -0
  21. package/dist/gateway/index.d.ts +2 -0
  22. package/dist/gateway/index.js +2 -0
  23. package/dist/gateway/policy-resolver.d.ts +57 -0
  24. package/dist/gateway/policy-resolver.js +123 -0
  25. package/dist/gateway/runtimes/acp-stream.d.ts +99 -0
  26. package/dist/gateway/runtimes/acp-stream.js +394 -0
  27. package/dist/gateway/runtimes/codex.js +7 -0
  28. package/dist/gateway/runtimes/hermes-agent.d.ts +83 -0
  29. package/dist/gateway/runtimes/hermes-agent.js +180 -0
  30. package/dist/gateway/runtimes/ndjson-stream.d.ts +7 -2
  31. package/dist/gateway/runtimes/ndjson-stream.js +16 -3
  32. package/dist/gateway/runtimes/openclaw-acp.d.ts +44 -0
  33. package/dist/gateway/runtimes/openclaw-acp.js +500 -0
  34. package/dist/gateway/runtimes/registry.d.ts +4 -0
  35. package/dist/gateway/runtimes/registry.js +22 -0
  36. package/dist/gateway/transcript-paths.d.ts +30 -0
  37. package/dist/gateway/transcript-paths.js +114 -0
  38. package/dist/gateway/transcript.d.ts +123 -0
  39. package/dist/gateway/transcript.js +147 -0
  40. package/dist/gateway/types.d.ts +31 -0
  41. package/dist/index.js +286 -27
  42. package/dist/mention-scan.d.ts +22 -0
  43. package/dist/mention-scan.js +35 -0
  44. package/dist/provision.d.ts +73 -3
  45. package/dist/provision.js +373 -12
  46. package/dist/system-context.d.ts +5 -4
  47. package/dist/system-context.js +35 -5
  48. package/dist/turn-text.js +20 -1
  49. package/dist/url-utils.d.ts +9 -0
  50. package/dist/url-utils.js +18 -0
  51. package/dist/user-auth.js +0 -2
  52. package/dist/working-memory.js +1 -1
  53. package/package.json +2 -1
  54. package/src/__tests__/agent-workspace.test.ts +93 -0
  55. package/src/__tests__/daemon-config-map.test.ts +79 -0
  56. package/src/__tests__/openclaw-acp.test.ts +234 -0
  57. package/src/__tests__/policy-resolver.test.ts +124 -0
  58. package/src/__tests__/policy-updated-handler.test.ts +144 -0
  59. package/src/__tests__/provision.test.ts +160 -0
  60. package/src/__tests__/system-context.test.ts +52 -0
  61. package/src/__tests__/url-utils.test.ts +37 -0
  62. package/src/agent-discovery.ts +12 -4
  63. package/src/agent-workspace.ts +173 -9
  64. package/src/config.ts +132 -4
  65. package/src/control-channel.ts +1 -4
  66. package/src/daemon-config-map.ts +156 -12
  67. package/src/daemon.ts +66 -5
  68. package/src/doctor.ts +49 -2
  69. package/src/gateway/__tests__/dispatcher.test.ts +440 -2
  70. package/src/gateway/__tests__/hermes-agent-adapter.test.ts +302 -0
  71. package/src/gateway/__tests__/transcript.test.ts +496 -0
  72. package/src/gateway/cli-resolver.ts +92 -0
  73. package/src/gateway/dispatcher.ts +681 -58
  74. package/src/gateway/gateway.ts +46 -0
  75. package/src/gateway/index.ts +25 -0
  76. package/src/gateway/policy-resolver.ts +171 -0
  77. package/src/gateway/runtimes/acp-stream.ts +535 -0
  78. package/src/gateway/runtimes/codex.ts +7 -0
  79. package/src/gateway/runtimes/hermes-agent.ts +206 -0
  80. package/src/gateway/runtimes/ndjson-stream.ts +16 -3
  81. package/src/gateway/runtimes/openclaw-acp.ts +606 -0
  82. package/src/gateway/runtimes/registry.ts +24 -0
  83. package/src/gateway/transcript-paths.ts +145 -0
  84. package/src/gateway/transcript.ts +300 -0
  85. package/src/gateway/types.ts +32 -0
  86. package/src/index.ts +295 -30
  87. package/src/mention-scan.ts +38 -0
  88. package/src/provision.ts +446 -20
  89. package/src/system-context.ts +41 -9
  90. package/src/turn-text.ts +22 -1
  91. package/src/url-utils.ts +17 -0
  92. package/src/user-auth.ts +0 -2
  93. package/src/working-memory.ts +1 -1
@@ -0,0 +1,114 @@
1
+ import { createHash } from "node:crypto";
2
+ import path from "node:path";
3
+ const FAST_PATH_RE = /^[A-Za-z0-9_-]{1,128}$/;
4
+ const WIN_RESERVED = new Set([
5
+ "CON", "PRN", "AUX", "NUL",
6
+ "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
7
+ "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
8
+ ]);
9
+ const MAX_LEN = 200;
10
+ const TRUNCATE_PREFIX = 191;
11
+ function sha256_8(raw) {
12
+ return createHash("sha256").update(raw).digest("hex").slice(0, 8);
13
+ }
14
+ function isControlOrNul(ch) {
15
+ return ch === 0 || ch < 0x20 || ch === 0x7f;
16
+ }
17
+ function isAllControl(raw) {
18
+ for (let i = 0; i < raw.length; i++) {
19
+ if (!isControlOrNul(raw.charCodeAt(i)))
20
+ return false;
21
+ }
22
+ return true;
23
+ }
24
+ function percentEncodeByte(byte) {
25
+ return "%" + byte.toString(16).toUpperCase().padStart(2, "0");
26
+ }
27
+ function isWhitelistByte(byte) {
28
+ // [A-Za-z0-9_-%] retained as literal
29
+ return ((byte >= 0x30 && byte <= 0x39) || // 0-9
30
+ (byte >= 0x41 && byte <= 0x5a) || // A-Z
31
+ (byte >= 0x61 && byte <= 0x7a) || // a-z
32
+ byte === 0x5f || // _
33
+ byte === 0x2d || // -
34
+ byte === 0x25 // % (kept literal — design §3.1)
35
+ );
36
+ }
37
+ function escapeRaw(raw) {
38
+ const bytes = Buffer.from(raw, "utf8");
39
+ let out = "";
40
+ for (const b of bytes) {
41
+ out += isWhitelistByte(b) ? String.fromCharCode(b) : percentEncodeByte(b);
42
+ }
43
+ return out;
44
+ }
45
+ /**
46
+ * Truncate an escaped string to exactly MAX_LEN chars without splitting a `%XX`
47
+ * sequence. Keep first TRUNCATE_PREFIX chars (rolled back if mid-`%XX`), then
48
+ * `_` + sha256-8(raw) so the total is always ≤ MAX_LEN.
49
+ */
50
+ function truncateEscaped(escaped, raw) {
51
+ let cut = TRUNCATE_PREFIX;
52
+ // Roll back if cut sits inside a `%XX` sequence.
53
+ // A '%' at position cut-1 or cut-2 means the next 1 or 2 chars belong to it.
54
+ if (cut >= 1 && escaped[cut - 1] === "%")
55
+ cut -= 1;
56
+ else if (cut >= 2 && escaped[cut - 2] === "%")
57
+ cut -= 2;
58
+ const hash = sha256_8(raw);
59
+ return escaped.slice(0, cut) + "_" + hash;
60
+ }
61
+ /**
62
+ * Convert a raw ID into a filesystem-safe path segment.
63
+ *
64
+ * Order (must not be reordered — see design §3.1):
65
+ * 1. obviously invalid (empty / `.` / `..` / all control/NUL)
66
+ * → `_invalid_<sha256-8>`
67
+ * 2. Windows reserved name (CON/PRN/AUX/NUL/COM1-9/LPT1-9, case-insensitive)
68
+ * → `_win_<raw>`
69
+ * 3. fast path (`^[A-Za-z0-9_-]{1,128}$`) → return raw
70
+ * 4. percent-encode non-whitelist bytes; truncate at 200 chars without
71
+ * splitting a `%XX` (191 prefix + `_` + sha256-8)
72
+ *
73
+ * The original ID is always written into the transcript record itself; this
74
+ * helper only sanitizes the on-disk filename.
75
+ */
76
+ export function safePathSegment(raw) {
77
+ // 1. obviously invalid
78
+ if (raw === "" || raw === "." || raw === ".." || isAllControl(raw)) {
79
+ return "_invalid_" + sha256_8(raw);
80
+ }
81
+ // 2. Windows reserved names — must run BEFORE fast path so `CON` is not
82
+ // leaked unchanged on case-insensitive filesystems.
83
+ if (WIN_RESERVED.has(raw.toUpperCase())) {
84
+ return "_win_" + raw;
85
+ }
86
+ // 3. fast path
87
+ if (FAST_PATH_RE.test(raw))
88
+ return raw;
89
+ // 4. escape + maybe truncate
90
+ const escaped = escapeRaw(raw);
91
+ if (escaped.length <= MAX_LEN)
92
+ return escaped;
93
+ return truncateEscaped(escaped, raw);
94
+ }
95
+ /**
96
+ * Resolve the on-disk transcript file for a given (agent, room, topic). Used
97
+ * by the writer AND the CLI subcommands so both look at the same file.
98
+ *
99
+ * Layout (design §3.1):
100
+ * <rootDir>/<agentId>/transcripts/<roomId>/<topicId|_default>.jsonl
101
+ *
102
+ * Where <rootDir> is typically `~/.botcord/agents`.
103
+ */
104
+ export function transcriptFilePath(rootDir, agentId, roomId, topicId) {
105
+ return path.join(rootDir, safePathSegment(agentId), "transcripts", safePathSegment(roomId), (topicId === null ? "_default" : safePathSegment(topicId)) + ".jsonl");
106
+ }
107
+ /** Directory holding a (agent, room) pair's transcript files. */
108
+ export function transcriptRoomDir(rootDir, agentId, roomId) {
109
+ return path.join(rootDir, safePathSegment(agentId), "transcripts", safePathSegment(roomId));
110
+ }
111
+ /** Directory holding all transcript rooms for a single agent. */
112
+ export function transcriptAgentRoot(rootDir, agentId) {
113
+ return path.join(rootDir, safePathSegment(agentId), "transcripts");
114
+ }
@@ -0,0 +1,123 @@
1
+ import type { GatewayLogger } from "./log.js";
2
+ /**
3
+ * Soft cap on a single textual field (`text` / `composedText` / `finalText`).
4
+ * Anything longer is truncated and `truncated.<field>` set to `true`.
5
+ */
6
+ export declare const TRANSCRIPT_TEXT_LIMIT: number;
7
+ /** Soft cap on a single transcript file before rotation. */
8
+ export declare const TRANSCRIPT_FILE_LIMIT: number;
9
+ /** Default root directory for per-agent transcript trees. */
10
+ export declare function defaultTranscriptRoot(): string;
11
+ export type TranscriptRecordKind = "inbound" | "dispatched" | "compose_failed" | "outbound" | "turn_error" | "attention_skipped" | "dropped";
12
+ export interface TranscriptRecordBase {
13
+ ts: string;
14
+ kind: TranscriptRecordKind;
15
+ turnId: string;
16
+ agentId: string;
17
+ roomId: string;
18
+ topicId: string | null;
19
+ }
20
+ export interface TranscriptSenderInfo {
21
+ id: string;
22
+ kind: "user" | "agent" | "system";
23
+ name?: string;
24
+ }
25
+ export interface InboundTranscriptRecord extends TranscriptRecordBase {
26
+ kind: "inbound";
27
+ messageId: string;
28
+ sender: TranscriptSenderInfo;
29
+ text: string;
30
+ rawBatchEntries?: number;
31
+ trace?: {
32
+ id: string;
33
+ streamable?: boolean;
34
+ };
35
+ truncated?: {
36
+ text?: true;
37
+ };
38
+ }
39
+ export interface DispatchedTranscriptRecord extends TranscriptRecordBase {
40
+ kind: "dispatched";
41
+ composedText: string;
42
+ mergedFromTurnIds?: string[];
43
+ runtime: string;
44
+ truncated?: {
45
+ composedText?: true;
46
+ };
47
+ }
48
+ export interface ComposeFailedTranscriptRecord extends TranscriptRecordBase {
49
+ kind: "compose_failed";
50
+ error: string;
51
+ fallback: "raw_text";
52
+ }
53
+ export type DeliveryStatus = "delivered" | "gated_non_owner_chat" | "empty_text" | "send_failed";
54
+ export interface TranscriptBlockSummary {
55
+ type: string;
56
+ chars?: number;
57
+ name?: string;
58
+ }
59
+ export interface OutboundTranscriptRecord extends TranscriptRecordBase {
60
+ kind: "outbound";
61
+ runtime: string;
62
+ runtimeSessionId?: string | null;
63
+ durationMs: number;
64
+ costUsd?: number;
65
+ finalText: string;
66
+ deliveryStatus: DeliveryStatus;
67
+ deliveryReason?: string | null;
68
+ blocks?: TranscriptBlockSummary[];
69
+ truncated?: {
70
+ finalText?: true;
71
+ };
72
+ }
73
+ export interface TurnErrorTranscriptRecord extends TranscriptRecordBase {
74
+ kind: "turn_error";
75
+ phase: "runtime" | "timeout";
76
+ error: string;
77
+ durationMs: number;
78
+ }
79
+ export interface AttentionSkippedTranscriptRecord extends TranscriptRecordBase {
80
+ kind: "attention_skipped";
81
+ reason: string;
82
+ }
83
+ export type DroppedReason = "batch_merged" | "queue_cancel_previous" | "queue_overflow";
84
+ export interface DroppedTranscriptRecord extends TranscriptRecordBase {
85
+ kind: "dropped";
86
+ reason: DroppedReason;
87
+ supersededBy?: string | null;
88
+ }
89
+ export type TranscriptRecord = InboundTranscriptRecord | DispatchedTranscriptRecord | ComposeFailedTranscriptRecord | OutboundTranscriptRecord | TurnErrorTranscriptRecord | AttentionSkippedTranscriptRecord | DroppedTranscriptRecord;
90
+ /**
91
+ * Truncate `value` to TRANSCRIPT_TEXT_LIMIT chars. Returns the (possibly
92
+ * truncated) text and whether truncation occurred. Surrogate-pair aware: if
93
+ * the cut would split a pair, step back one char.
94
+ */
95
+ export declare function truncateTextField(value: string): {
96
+ text: string;
97
+ truncated: boolean;
98
+ };
99
+ export interface TranscriptWriter {
100
+ /** Append a record. Failures are logged and swallowed. */
101
+ write(rec: TranscriptRecord): void;
102
+ /** Whether persistence is on. CLI / tests may read this. */
103
+ readonly enabled: boolean;
104
+ /** Root directory used for path resolution. */
105
+ readonly rootDir: string;
106
+ }
107
+ export interface CreateTranscriptWriterOptions {
108
+ /** Defaults to `~/.botcord/agents`. */
109
+ rootDir?: string;
110
+ log: GatewayLogger;
111
+ /** Defaults to `false` — see design §6 (default-off). */
112
+ enabled?: boolean;
113
+ /** Override file rotation threshold (bytes). Defaults to TRANSCRIPT_FILE_LIMIT. */
114
+ maxFileBytes?: number;
115
+ }
116
+ export declare function createTranscriptWriter(opts: CreateTranscriptWriterOptions): TranscriptWriter;
117
+ /**
118
+ * Resolve the tri-state enable flag (env wins; otherwise config). See design §5.
119
+ * - env === "1" → true (force on)
120
+ * - env === "0" → false (force off)
121
+ * - any other / unset → fall back to `configEnabled`
122
+ */
123
+ export declare function resolveTranscriptEnabled(envVal: string | undefined, configEnabled: boolean): boolean;
@@ -0,0 +1,147 @@
1
+ import { appendFileSync, mkdirSync, renameSync, statSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import path from "node:path";
4
+ import { transcriptFilePath } from "./transcript-paths.js";
5
+ /**
6
+ * Soft cap on a single textual field (`text` / `composedText` / `finalText`).
7
+ * Anything longer is truncated and `truncated.<field>` set to `true`.
8
+ */
9
+ export const TRANSCRIPT_TEXT_LIMIT = 32 * 1024;
10
+ /** Soft cap on a single transcript file before rotation. */
11
+ export const TRANSCRIPT_FILE_LIMIT = 8 * 1024 * 1024;
12
+ /** Default root directory for per-agent transcript trees. */
13
+ export function defaultTranscriptRoot() {
14
+ return path.join(homedir(), ".botcord", "agents");
15
+ }
16
+ // ---------------------------------------------------------------------------
17
+ // Truncation helper
18
+ // ---------------------------------------------------------------------------
19
+ /**
20
+ * Truncate `value` to TRANSCRIPT_TEXT_LIMIT chars. Returns the (possibly
21
+ * truncated) text and whether truncation occurred. Surrogate-pair aware: if
22
+ * the cut would split a pair, step back one char.
23
+ */
24
+ export function truncateTextField(value) {
25
+ if (value.length <= TRANSCRIPT_TEXT_LIMIT)
26
+ return { text: value, truncated: false };
27
+ let cut = TRANSCRIPT_TEXT_LIMIT;
28
+ const code = value.charCodeAt(cut - 1);
29
+ if (code >= 0xd800 && code <= 0xdbff)
30
+ cut -= 1; // mid-surrogate
31
+ return { text: value.slice(0, cut), truncated: true };
32
+ }
33
+ class NoopTranscriptWriter {
34
+ enabled = false;
35
+ rootDir;
36
+ constructor(rootDir) {
37
+ this.rootDir = rootDir;
38
+ }
39
+ write(_rec) {
40
+ // intentionally empty
41
+ }
42
+ }
43
+ class FsTranscriptWriter {
44
+ enabled = true;
45
+ rootDir;
46
+ log;
47
+ maxFileBytes;
48
+ fileMeta = new Map();
49
+ firstWriteAnnounced = false;
50
+ constructor(rootDir, log, maxFileBytes) {
51
+ this.rootDir = rootDir;
52
+ this.log = log;
53
+ this.maxFileBytes = maxFileBytes;
54
+ }
55
+ write(rec) {
56
+ try {
57
+ const file = transcriptFilePath(this.rootDir, rec.agentId, rec.roomId, rec.topicId);
58
+ const dir = path.dirname(file);
59
+ try {
60
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
61
+ }
62
+ catch {
63
+ // best-effort — appendFileSync below will surface a real error
64
+ }
65
+ const line = JSON.stringify(rec) + "\n";
66
+ const bytes = Buffer.byteLength(line, "utf8");
67
+ // Rotate before appending if the existing file would exceed the cap.
68
+ const meta = this.statFile(file);
69
+ if (meta.size > 0 && meta.size + bytes > this.maxFileBytes) {
70
+ this.rotate(file);
71
+ this.fileMeta.delete(file);
72
+ }
73
+ appendFileSync(file, line, { mode: 0o600 });
74
+ const cur = this.fileMeta.get(file) ?? { size: meta.size };
75
+ cur.size = (cur.size || 0) + bytes;
76
+ this.fileMeta.set(file, cur);
77
+ if (!this.firstWriteAnnounced) {
78
+ this.firstWriteAnnounced = true;
79
+ this.log.info("transcript enabled", { dir: this.rootDir });
80
+ }
81
+ }
82
+ catch (err) {
83
+ this.log.warn("transcript: write failed", {
84
+ kind: rec.kind,
85
+ turnId: rec.turnId,
86
+ error: err instanceof Error ? err.message : String(err),
87
+ });
88
+ }
89
+ }
90
+ statFile(file) {
91
+ const cached = this.fileMeta.get(file);
92
+ if (cached)
93
+ return cached;
94
+ try {
95
+ const st = statSync(file);
96
+ const meta = { size: st.size };
97
+ this.fileMeta.set(file, meta);
98
+ return meta;
99
+ }
100
+ catch {
101
+ const meta = { size: 0 };
102
+ this.fileMeta.set(file, meta);
103
+ return meta;
104
+ }
105
+ }
106
+ rotate(file) {
107
+ const stamp = formatStamp(new Date());
108
+ const ext = ".jsonl";
109
+ const base = file.endsWith(ext) ? file.slice(0, -ext.length) : file;
110
+ const rotated = `${base}.${stamp}${ext}`;
111
+ try {
112
+ renameSync(file, rotated);
113
+ }
114
+ catch (err) {
115
+ this.log.warn("transcript: rotate failed", {
116
+ file,
117
+ error: err instanceof Error ? err.message : String(err),
118
+ });
119
+ }
120
+ }
121
+ }
122
+ export function createTranscriptWriter(opts) {
123
+ const rootDir = opts.rootDir ?? defaultTranscriptRoot();
124
+ const enabled = opts.enabled ?? false;
125
+ if (!enabled)
126
+ return new NoopTranscriptWriter(rootDir);
127
+ const maxBytes = opts.maxFileBytes ?? TRANSCRIPT_FILE_LIMIT;
128
+ return new FsTranscriptWriter(rootDir, opts.log, maxBytes);
129
+ }
130
+ /**
131
+ * Resolve the tri-state enable flag (env wins; otherwise config). See design §5.
132
+ * - env === "1" → true (force on)
133
+ * - env === "0" → false (force off)
134
+ * - any other / unset → fall back to `configEnabled`
135
+ */
136
+ export function resolveTranscriptEnabled(envVal, configEnabled) {
137
+ if (envVal === "1")
138
+ return true;
139
+ if (envVal === "0")
140
+ return false;
141
+ return configEnabled;
142
+ }
143
+ function formatStamp(d) {
144
+ const pad = (n) => n.toString().padStart(2, "0");
145
+ return (`${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}` +
146
+ `-${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}`);
147
+ }
@@ -13,6 +13,19 @@ export interface RouteMatch {
13
13
  export type QueueMode = "serial" | "cancel-previous";
14
14
  /** Source-based trust tier used by runtimes to pick default permission flags. */
15
15
  export type TrustLevel = "owner" | "trusted" | "public";
16
+ /**
17
+ * Resolved OpenClaw gateway endpoint for a route. Built eagerly in
18
+ * `toGatewayConfig` from the `DaemonConfig.openclawGateways` registry plus the
19
+ * `RouteRule.gateway` / `openclawAgent` choice — the dispatcher never needs
20
+ * to re-query the registry. `name` is preserved purely for logging/snapshot.
21
+ */
22
+ export interface ResolvedOpenclawGateway {
23
+ name: string;
24
+ url: string;
25
+ token?: string;
26
+ /** OpenClaw agent profile, with the route override already applied. */
27
+ openclawAgent?: string;
28
+ }
16
29
  /** Declarative route entry selecting the runtime and execution flags for matched messages. */
17
30
  export interface GatewayRoute {
18
31
  match?: RouteMatch;
@@ -21,6 +34,8 @@ export interface GatewayRoute {
21
34
  extraArgs?: string[];
22
35
  queueMode?: QueueMode;
23
36
  trustLevel?: TrustLevel;
37
+ /** Required when `runtime === "openclaw-acp"`. Resolved at config-load time. */
38
+ gateway?: ResolvedOpenclawGateway;
24
39
  }
25
40
  /**
26
41
  * Per-channel configuration entry. Channel-specific extras (e.g. BotCord
@@ -214,6 +229,15 @@ export interface RuntimeRunOptions {
214
229
  * per-agent `CODEX_HOME` carrying the AGENTS.md that injects systemContext.
215
230
  */
216
231
  accountId: string;
232
+ /**
233
+ * Hub URL the owning agent is registered against. Forwarded to runtimes
234
+ * so spawned CLI subprocesses can target the correct hub via
235
+ * `BOTCORD_HUB` (see `cli-resolver.buildCliEnv`). Optional because the
236
+ * dispatcher cannot always resolve a per-agent hub (e.g. for agents
237
+ * provisioned after boot); when unset, runtimes leave `BOTCORD_HUB`
238
+ * unspecified and the bundled CLI falls back to its own default.
239
+ */
240
+ hubUrl?: string;
217
241
  signal: AbortSignal;
218
242
  extraArgs?: string[];
219
243
  trustLevel: TrustLevel;
@@ -223,6 +247,13 @@ export interface RuntimeRunOptions {
223
247
  context?: Record<string, unknown>;
224
248
  /** Called for every parsed block while the turn is in progress. */
225
249
  onBlock?: (block: StreamBlock) => void;
250
+ /**
251
+ * External service endpoint required by some runtimes (first user:
252
+ * openclaw-acp). Resolved at config-load time and passed through here per
253
+ * call — runtime factories do not see it. Mirrors the `hubUrl` precedent of
254
+ * lifting service URLs out of `extraArgs` into typed first-class fields.
255
+ */
256
+ gateway?: ResolvedOpenclawGateway;
226
257
  }
227
258
  /** Result returned by a runtime adapter after a turn completes. */
228
259
  export interface RuntimeRunResult {