@botcord/daemon 0.2.5 → 0.2.8

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