@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
@@ -1,6 +1,69 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import path from "node:path";
1
4
  import { resolveAgentIds } from "./config.js";
2
5
  import { agentWorkspaceDir } from "./agent-workspace.js";
3
6
  import { log as daemonLog } from "./log.js";
7
+ function expandHome(p) {
8
+ if (p === "~")
9
+ return homedir();
10
+ if (p.startsWith("~/"))
11
+ return path.join(homedir(), p.slice(2));
12
+ return p;
13
+ }
14
+ /** Resolve one profile's token (inline > tokenFile). Failures are swallowed
15
+ * into `tokenError`; `resolvedToken` is left undefined. Logs at warn for ops
16
+ * visibility. */
17
+ export function prepareGatewayProfile(p) {
18
+ const prepared = { ...p };
19
+ if (p.token && p.token.length > 0) {
20
+ prepared.resolvedToken = p.token;
21
+ }
22
+ else if (p.tokenFile && p.tokenFile.length > 0) {
23
+ try {
24
+ prepared.resolvedToken = readFileSync(expandHome(p.tokenFile), "utf8").trim();
25
+ }
26
+ catch (err) {
27
+ prepared.tokenError = err?.message ?? String(err);
28
+ daemonLog.warn("daemon.config.openclaw.tokenfile_failed", {
29
+ gateway: p.name,
30
+ tokenFile: p.tokenFile,
31
+ error: prepared.tokenError,
32
+ });
33
+ }
34
+ }
35
+ return prepared;
36
+ }
37
+ /** Build a name → prepared-profile map for a config's gateway registry. */
38
+ export function prepareGatewayProfiles(profiles) {
39
+ const out = new Map();
40
+ if (!profiles)
41
+ return out;
42
+ for (const p of profiles)
43
+ out.set(p.name, prepareGatewayProfile(p));
44
+ return out;
45
+ }
46
+ function resolveGateway(profiles, gatewayName, agentOverride, where) {
47
+ if (!gatewayName) {
48
+ daemonLog.warn("daemon.config.openclaw.missing_gateway", { where });
49
+ return undefined;
50
+ }
51
+ const profile = profiles.get(gatewayName);
52
+ if (!profile) {
53
+ daemonLog.warn("daemon.config.openclaw.unknown_gateway", { where, gateway: gatewayName });
54
+ return undefined;
55
+ }
56
+ const resolved = {
57
+ name: profile.name,
58
+ url: profile.url,
59
+ };
60
+ if (profile.resolvedToken)
61
+ resolved.token = profile.resolvedToken;
62
+ const agent = agentOverride ?? profile.defaultAgent;
63
+ if (agent)
64
+ resolved.openclawAgent = agent;
65
+ return resolved;
66
+ }
4
67
  /**
5
68
  * Historical channel id used when the daemon bound a single agent. Kept as a
6
69
  * named export for any downstream reader that still references it; no new
@@ -29,7 +92,7 @@ function mapTrustLevel(level) {
29
92
  * legacy alias and its canonical field are present, the canonical field
30
93
  * wins and a warning is logged.
31
94
  */
32
- function mapRoute(r) {
95
+ function mapRoute(r, profiles, index) {
33
96
  const match = {};
34
97
  if (r.match.channel)
35
98
  match.channel = r.match.channel;
@@ -66,13 +129,17 @@ function mapRoute(r) {
66
129
  if (typeof r.match.mentioned === "boolean")
67
130
  match.mentioned = r.match.mentioned;
68
131
  const rawTrust = r.trustLevel;
69
- return {
132
+ const out = {
70
133
  match,
71
134
  runtime: r.adapter,
72
135
  cwd: r.cwd,
73
136
  extraArgs: r.extraArgs,
74
137
  trustLevel: mapTrustLevel(rawTrust),
75
138
  };
139
+ if (r.adapter === "openclaw-acp") {
140
+ out.gateway = resolveGateway(profiles, r.gateway, r.openclawAgent, `routes[${index}]`);
141
+ }
142
+ return out;
76
143
  }
77
144
  /**
78
145
  * Convert the daemon's on-disk config into a gateway runtime config. Only
@@ -100,6 +167,7 @@ export function toGatewayConfig(cfg, opts = {}) {
100
167
  }));
101
168
  // DaemonConfig's typed surface doesn't carry `trustLevel`, but we read it
102
169
  // defensively so future config extensions can propagate without a shape bump.
170
+ const profiles = prepareGatewayProfiles(cfg.openclawGateways);
103
171
  const rawDefaultTrust = cfg.defaultRoute
104
172
  .trustLevel;
105
173
  const defaultRoute = {
@@ -110,14 +178,18 @@ export function toGatewayConfig(cfg, opts = {}) {
110
178
  // (direct → cancel-previous, group → serial).
111
179
  trustLevel: mapTrustLevel(rawDefaultTrust),
112
180
  };
113
- const routes = (cfg.routes ?? []).map(mapRoute);
181
+ if (cfg.defaultRoute.adapter === "openclaw-acp") {
182
+ const dr = cfg.defaultRoute;
183
+ defaultRoute.gateway = resolveGateway(profiles, dr.gateway, dr.openclawAgent, "defaultRoute");
184
+ }
185
+ const routes = (cfg.routes ?? []).map((r, i) => mapRoute(r, profiles, i));
114
186
  // Synthesize a per-agent route for every bound agent and hand it to the
115
187
  // gateway via the managed-routes bucket (plan §10.1). User-authored
116
188
  // `cfg.routes[]` stay untouched. Match priority (see router.ts):
117
189
  // `routes[] with explicit accountId → managedRoutes → other routes[] →
118
190
  // defaultRoute`. Broad prefix/kind rules no longer clobber the agent's
119
191
  // chosen runtime — only routes that name the agent by `accountId` do.
120
- const managedMap = buildManagedRoutes(agentIds, opts.agentRuntimes ?? {}, defaultRoute);
192
+ const managedMap = buildManagedRoutes(agentIds, opts.agentRuntimes ?? {}, defaultRoute, profiles);
121
193
  return {
122
194
  channels,
123
195
  defaultRoute,
@@ -140,15 +212,40 @@ export function toGatewayConfig(cfg, opts = {}) {
140
212
  * Exported so `reload_config` and `provisionAgent` hot-add can share the
141
213
  * same synthesis logic (plan §10.5).
142
214
  */
143
- export function buildManagedRoutes(agentIds, agentRuntimes, defaultRoute) {
215
+ export function buildManagedRoutes(agentIds, agentRuntimes, defaultRoute, openclawProfiles) {
144
216
  const out = new Map();
217
+ // Lazy-build profile map when caller didn't pass one (legacy callers).
218
+ const profiles = openclawProfiles ?? new Map();
145
219
  for (const agentId of agentIds) {
146
220
  const meta = agentRuntimes[agentId] ?? {};
147
- out.set(agentId, {
221
+ const runtime = meta.runtime ?? defaultRoute.runtime;
222
+ const route = {
148
223
  match: { accountId: agentId },
149
- runtime: meta.runtime ?? defaultRoute.runtime,
224
+ runtime,
150
225
  cwd: meta.cwd || agentWorkspaceDir(agentId),
151
- });
226
+ // Inherit defaultRoute's extraArgs so synthesized per-agent routes
227
+ // pick up operator-wide flags (e.g. `--permission-mode bypassPermissions`)
228
+ // that would otherwise apply only to agents listed in `cfg.routes[]`.
229
+ ...(defaultRoute.extraArgs ? { extraArgs: defaultRoute.extraArgs.slice() } : {}),
230
+ };
231
+ if (runtime === "openclaw-acp") {
232
+ // Per RFC §3.4: prefer credentials, fall back to defaultRoute.gateway.
233
+ const gatewayName = meta.openclawGateway ?? defaultRoute.gateway?.name;
234
+ const agentOverride = meta.openclawAgent;
235
+ const resolved = gatewayName
236
+ ? resolveGateway(profiles, gatewayName, agentOverride, `managedRoute[${agentId}]`)
237
+ : defaultRoute.gateway;
238
+ if (!resolved) {
239
+ // No usable gateway — skip the managed route so defaultRoute can take over.
240
+ daemonLog.warn("daemon.config.openclaw.managed_route_skipped", {
241
+ agentId,
242
+ gatewayName,
243
+ });
244
+ continue;
245
+ }
246
+ route.gateway = resolved;
247
+ }
248
+ out.set(agentId, route);
152
249
  }
153
250
  return out;
154
251
  }
package/dist/daemon.d.ts CHANGED
@@ -108,6 +108,8 @@ export interface BootBackfillResult {
108
108
  agentRuntimes: Record<string, {
109
109
  runtime?: string;
110
110
  cwd?: string;
111
+ openclawGateway?: string;
112
+ openclawAgent?: string;
111
113
  }>;
112
114
  }
113
115
  /**
package/dist/daemon.js CHANGED
@@ -1,5 +1,5 @@
1
- import { CONTROL_FRAME_TYPES } from "@botcord/protocol-core";
2
- import { Gateway, createBotCordChannel, sanitizeUntrustedContent, } from "./gateway/index.js";
1
+ import { CONTROL_FRAME_TYPES, shouldWake, } from "@botcord/protocol-core";
2
+ import { Gateway, createBotCordChannel, resolveTranscriptEnabled, sanitizeUntrustedContent, } from "./gateway/index.js";
3
3
  import { ActivityTracker } from "./activity-tracker.js";
4
4
  import { SESSIONS_PATH, SNAPSHOT_PATH } from "./config.js";
5
5
  import { resolveBootAgents } from "./agent-discovery.js";
@@ -15,6 +15,8 @@ import { createRoomContextFetcher } from "./room-context-fetcher.js";
15
15
  import { buildLoopRiskPrompt, loopRiskSessionKey, recordInboundText as recordLoopRiskInbound, recordOutboundText as recordLoopRiskOutbound, } from "./loop-risk.js";
16
16
  import { composeBotCordUserTurn } from "./turn-text.js";
17
17
  import { UserAuthManager } from "./user-auth.js";
18
+ import { PolicyResolver } from "./gateway/policy-resolver.js";
19
+ import { scanMention } from "./mention-scan.js";
18
20
  /**
19
21
  * Matches the 10-minute turn timeout the legacy daemon dispatcher used, so
20
22
  * long-running CLI turns behave the same way under the gateway core.
@@ -124,6 +126,17 @@ export async function startDaemon(opts) {
124
126
  const agentIds = boot.agents.map((a) => a.agentId);
125
127
  const { credentialPathByAgentId, agentRuntimes } = backfillBootAgents(boot.agents, { logger });
126
128
  const gwConfig = toGatewayConfig(opts.config, { agentIds, agentRuntimes });
129
+ // Per-agent hub URL — read from each credential file at boot. Used to
130
+ // populate `BOTCORD_HUB` for runtime CLI subprocesses so the bundled
131
+ // `botcord` CLI talks to the same hub the agent is registered against,
132
+ // even when a single daemon hosts agents from different hubs.
133
+ const hubUrlByAgentId = new Map();
134
+ for (const a of boot.agents) {
135
+ if (a.hubUrl)
136
+ hubUrlByAgentId.set(a.agentId, a.hubUrl);
137
+ }
138
+ const fallbackHubUrl = opts.hubBaseUrl;
139
+ const resolveHubUrl = (accountId) => hubUrlByAgentId.get(accountId) ?? fallbackHubUrl;
127
140
  // ActivityTracker lives at the daemon layer (not the gateway core). We
128
141
  // expose it to the gateway via (a) the `buildSystemContext` hook so the
129
142
  // cross-room digest reflects current activity, and (b) the `onInbound`
@@ -206,6 +219,35 @@ export async function startDaemon(opts) {
206
219
  text: out.text,
207
220
  });
208
221
  };
222
+ // Per-agent attention policy cache (PR3, design §4.2 / §5). Seeded from
223
+ // the optional `defaultAttention` / `attentionKeywords` carried by
224
+ // `provision_agent`, refreshed in-place by the `policy_updated` control
225
+ // frame. PR2 will plug per-room overrides into `fetchEffective`; PR3
226
+ // leaves it absent so the resolver collapses to per-agent state.
227
+ const policyResolver = new PolicyResolver({
228
+ fetchGlobal: async (_agentId) => undefined,
229
+ });
230
+ // Display-name lookup for the mention text-fallback. Populated from boot
231
+ // credentials; multi-agent daemons can reuse the same map via accountId.
232
+ const displayNameByAgent = new Map();
233
+ for (const a of boot.agents) {
234
+ if (a.displayName)
235
+ displayNameByAgent.set(a.agentId, a.displayName);
236
+ }
237
+ // Attention gate: compose `messages.mentioned` (sender-supplied — distrust)
238
+ // with a local `@<display_name>` / `@<agent_id>` text scan, resolve the
239
+ // effective policy, then defer to the protocol-core `shouldWake` decision.
240
+ const attentionGate = async (msg) => {
241
+ const policy = await policyResolver.resolve(msg.accountId, msg.conversation.id);
242
+ const localMention = scanMention(msg.text, {
243
+ agentId: msg.accountId,
244
+ displayName: displayNameByAgent.get(msg.accountId),
245
+ });
246
+ return shouldWake(policy, {
247
+ mentioned: msg.mentioned === true || localMention,
248
+ text: msg.text,
249
+ });
250
+ };
209
251
  const gateway = new Gateway({
210
252
  config: gwConfig,
211
253
  sessionStorePath: opts.sessionStorePath ?? SESSIONS_PATH,
@@ -225,6 +267,9 @@ export async function startDaemon(opts) {
225
267
  onInbound,
226
268
  onOutbound,
227
269
  composeUserTurn: composeBotCordUserTurn,
270
+ attentionGate,
271
+ resolveHubUrl,
272
+ transcriptEnabled: resolveTranscriptEnabled(process.env.BOTCORD_TRANSCRIPT, opts.config.transcript?.enabled === true),
228
273
  });
229
274
  logger.info("daemon starting", {
230
275
  agents: agentIds,
@@ -239,7 +284,7 @@ export async function startDaemon(opts) {
239
284
  logger.warn("daemon starting with no channels", {
240
285
  source: boot.source,
241
286
  credentialsDir: boot.credentialsDir,
242
- hint: "drop a credentials JSON in the discovery dir and restart, or run `botcord-daemon init --agent <ag_xxx>`",
287
+ hint: "drop a credentials JSON in the discovery dir and restart, or run `botcord-daemon start --agent <ag_xxx>` (only seeds config on first run)",
243
288
  });
244
289
  }
245
290
  await gateway.start();
@@ -256,7 +301,7 @@ export async function startDaemon(opts) {
256
301
  userId: userAuth.current.userId,
257
302
  hubUrl: userAuth.current.hubUrl,
258
303
  });
259
- const provisioner = createProvisioner({ gateway });
304
+ const provisioner = createProvisioner({ gateway, policyResolver });
260
305
  controlChannel = new ControlChannel({
261
306
  auth: userAuth,
262
307
  handle: provisioner,
@@ -329,10 +374,12 @@ export function backfillBootAgents(agents, opts) {
329
374
  for (const a of agents) {
330
375
  if (a.credentialsFile)
331
376
  credentialPathByAgentId.set(a.agentId, a.credentialsFile);
332
- if (a.runtime || a.cwd) {
377
+ if (a.runtime || a.cwd || a.openclawGateway || a.openclawAgent) {
333
378
  agentRuntimes[a.agentId] = {
334
379
  ...(a.runtime ? { runtime: a.runtime } : {}),
335
380
  ...(a.cwd ? { cwd: a.cwd } : {}),
381
+ ...(a.openclawGateway ? { openclawGateway: a.openclawGateway } : {}),
382
+ ...(a.openclawAgent ? { openclawAgent: a.openclawAgent } : {}),
336
383
  };
337
384
  }
338
385
  // Seed files are written only when missing (see `ensureAgentWorkspace`),
package/dist/doctor.d.ts CHANGED
@@ -25,9 +25,35 @@ export interface DoctorHttpResult {
25
25
  status?: number;
26
26
  error?: string;
27
27
  }
28
+ /** One endpoint probe entry, mirrored from `RuntimeEndpointProbe`. */
29
+ export interface DoctorRuntimeEndpoint {
30
+ name: string;
31
+ url: string;
32
+ reachable: boolean;
33
+ version?: string;
34
+ error?: string;
35
+ agents?: Array<{
36
+ id: string;
37
+ name?: string;
38
+ workspace?: string;
39
+ model?: {
40
+ name?: string;
41
+ provider?: string;
42
+ };
43
+ }>;
44
+ /**
45
+ * Optional warning surfaced by the doctor: e.g. botcord plugin loaded on
46
+ * the gateway (would form a daemon → openclaw → botcord → Hub loop).
47
+ */
48
+ warnings?: string[];
49
+ }
50
+ /** Augmented runtime entry that may carry endpoint probe results. */
51
+ export interface DoctorRuntimeEntry extends RuntimeProbeEntry {
52
+ endpoints?: DoctorRuntimeEndpoint[];
53
+ }
28
54
  /** Input for the rendered doctor output. */
29
55
  export interface DoctorInput {
30
- runtimes: RuntimeProbeEntry[];
56
+ runtimes: DoctorRuntimeEntry[];
31
57
  channels: ChannelProbeResult[];
32
58
  }
33
59
  /** Per-channel config entry accepted by {@link probeChannel}. */
package/dist/doctor.js CHANGED
@@ -152,8 +152,29 @@ export function renderDoctor(input) {
152
152
  version: Math.max(7, ...rows.map((r) => r.version.length)),
153
153
  };
154
154
  lines.push(`${pad("RUNTIME", widths.runtime)} ${pad("NAME", widths.name)} ${pad("STATUS", widths.status)} ${pad("VERSION", widths.version)} PATH`);
155
- for (const r of rows) {
155
+ for (let i = 0; i < rows.length; i += 1) {
156
+ const r = rows[i];
157
+ const e = input.runtimes[i];
156
158
  lines.push(`${pad(r.runtime, widths.runtime)} ${pad(r.name, widths.name)} ${pad(r.status, widths.status)} ${pad(r.version, widths.version)} ${r.path}`);
159
+ if (e.endpoints && e.endpoints.length > 0) {
160
+ for (const ep of e.endpoints) {
161
+ const mark = ep.reachable ? "✓" : "✗";
162
+ const detail = ep.reachable
163
+ ? ep.version ?? "ok"
164
+ : ep.error ?? "unreachable";
165
+ lines.push(` gateway ${pad(`"${ep.name}"`, 16)} ${pad(ep.url, 40)} ${mark} ${detail}`);
166
+ if (ep.agents && ep.agents.length > 0) {
167
+ // RFC §3.8.4: list by `id` (stable key); show display name when distinct.
168
+ lines.push(` agents (id): ${ep.agents
169
+ .map((a) => (a.name && a.name !== a.id ? `${a.id} (${a.name})` : a.id))
170
+ .join(", ")}`);
171
+ }
172
+ if (ep.warnings) {
173
+ for (const w of ep.warnings)
174
+ lines.push(` WARN: ${w}`);
175
+ }
176
+ }
177
+ }
157
178
  }
158
179
  const available = input.runtimes.filter((e) => e.result.available).length;
159
180
  lines.push(`\n${available}/${input.runtimes.length} runtimes available`);
@@ -0,0 +1,34 @@
1
+ export interface BundledCliBin {
2
+ /** Directory containing the `botcord` symlink — safe to prepend to PATH. */
3
+ binDir: string;
4
+ /** Absolute path to the CLI's JS entry — for direct spawn (not via PATH). */
5
+ binPath: string;
6
+ }
7
+ /**
8
+ * Resolve the bundled `@botcord/cli` package and return both the
9
+ * `<install-root>/node_modules/.bin` directory (for PATH injection so
10
+ * `botcord` shows up to runtimes) and the absolute JS entry (for callers
11
+ * that want to spawn the CLI directly without depending on the symlink).
12
+ *
13
+ * Returns `null` when `@botcord/cli` is not installed alongside the daemon
14
+ * — callers should fall back to whatever `botcord` is on the user's PATH.
15
+ */
16
+ export declare function resolveBundledCliBin(): BundledCliBin | null;
17
+ /** Test-only: clear the cached resolution. */
18
+ export declare function __resetBundledCliBinCache(): void;
19
+ /**
20
+ * Return env additions that point a runtime CLI subprocess at the right
21
+ * BotCord identity:
22
+ * - `BOTCORD_HUB` — hub URL the agent is registered against
23
+ * - `BOTCORD_AGENT_ID` — default `--agent` for `botcord ...` invocations
24
+ * - `PATH` — prepended with the bundled CLI's `.bin` dir so
25
+ * `botcord` resolves to the version daemon shipped
26
+ * with (avoiding protocol-core drift). Falls
27
+ * through to whatever the user already has on PATH
28
+ * when the bundled CLI can't be resolved.
29
+ */
30
+ export declare function buildCliEnv(opts: {
31
+ hubUrl?: string;
32
+ accountId?: string;
33
+ basePath?: string | undefined;
34
+ }): NodeJS.ProcessEnv;
@@ -0,0 +1,74 @@
1
+ import { createRequire } from "node:module";
2
+ import path from "node:path";
3
+ import fs from "node:fs";
4
+ import { consoleLogger } from "./log.js";
5
+ const require = createRequire(import.meta.url);
6
+ // Tri-state cache: `undefined` means "not yet attempted"; `null` means
7
+ // "attempted and unavailable" (don't retry, don't re-log).
8
+ let cached;
9
+ /**
10
+ * Resolve the bundled `@botcord/cli` package and return both the
11
+ * `<install-root>/node_modules/.bin` directory (for PATH injection so
12
+ * `botcord` shows up to runtimes) and the absolute JS entry (for callers
13
+ * that want to spawn the CLI directly without depending on the symlink).
14
+ *
15
+ * Returns `null` when `@botcord/cli` is not installed alongside the daemon
16
+ * — callers should fall back to whatever `botcord` is on the user's PATH.
17
+ */
18
+ export function resolveBundledCliBin() {
19
+ if (cached !== undefined)
20
+ return cached;
21
+ try {
22
+ const pkgJsonPath = require.resolve("@botcord/cli/package.json");
23
+ const pkgRoot = path.dirname(pkgJsonPath);
24
+ const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, "utf8"));
25
+ const binRel = typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.botcord;
26
+ if (!binRel) {
27
+ consoleLogger.warn("cli-resolver: @botcord/cli has no bin.botcord entry");
28
+ cached = null;
29
+ return null;
30
+ }
31
+ const binPath = path.resolve(pkgRoot, binRel);
32
+ // PATH must point at `<install-root>/node_modules/.bin` (where npm puts
33
+ // the `botcord` shim), not the package's own `dist/` — there is no
34
+ // executable named `botcord` inside the package directory.
35
+ const binDir = path.resolve(pkgRoot, "..", "..", ".bin");
36
+ cached = { binDir, binPath };
37
+ return cached;
38
+ }
39
+ catch (err) {
40
+ consoleLogger.warn("cli-resolver: bundled @botcord/cli not resolvable; runtimes will fall back to PATH", { error: err instanceof Error ? err.message : String(err) });
41
+ cached = null;
42
+ return null;
43
+ }
44
+ }
45
+ /** Test-only: clear the cached resolution. */
46
+ export function __resetBundledCliBinCache() {
47
+ cached = undefined;
48
+ }
49
+ /**
50
+ * Return env additions that point a runtime CLI subprocess at the right
51
+ * BotCord identity:
52
+ * - `BOTCORD_HUB` — hub URL the agent is registered against
53
+ * - `BOTCORD_AGENT_ID` — default `--agent` for `botcord ...` invocations
54
+ * - `PATH` — prepended with the bundled CLI's `.bin` dir so
55
+ * `botcord` resolves to the version daemon shipped
56
+ * with (avoiding protocol-core drift). Falls
57
+ * through to whatever the user already has on PATH
58
+ * when the bundled CLI can't be resolved.
59
+ */
60
+ export function buildCliEnv(opts) {
61
+ const env = {};
62
+ if (opts.hubUrl)
63
+ env.BOTCORD_HUB = opts.hubUrl;
64
+ if (opts.accountId)
65
+ env.BOTCORD_AGENT_ID = opts.accountId;
66
+ const cli = resolveBundledCliBin();
67
+ if (cli) {
68
+ const existing = opts.basePath ?? "";
69
+ env.PATH = existing
70
+ ? `${cli.binDir}${path.delimiter}${existing}`
71
+ : cli.binDir;
72
+ }
73
+ return env;
74
+ }
@@ -1,6 +1,7 @@
1
1
  import type { GatewayLogger } from "./log.js";
2
2
  import { type SessionStore } from "./session-store.js";
3
- import type { ChannelAdapter, GatewayConfig, GatewayInboundEnvelope, GatewayRoute, InboundObserver, OutboundObserver, RuntimeAdapter, SystemContextBuilder, TurnStatusSnapshot, UserTurnBuilder } from "./types.js";
3
+ import { type TranscriptWriter } from "./transcript.js";
4
+ import type { ChannelAdapter, GatewayConfig, GatewayInboundEnvelope, GatewayInboundMessage, GatewayRoute, InboundObserver, OutboundObserver, RuntimeAdapter, SystemContextBuilder, TurnStatusSnapshot, UserTurnBuilder } from "./types.js";
4
5
  /** Factory signature for building a runtime adapter at turn dispatch time. */
5
6
  export type RuntimeFactory = (runtimeId: string, extraArgs?: string[]) => RuntimeAdapter;
6
7
  /** Constructor options for `Dispatcher`. */
@@ -42,6 +43,30 @@ export interface DispatcherOptions {
42
43
  * and suppressed so observer failures never break the turn.
43
44
  */
44
45
  onOutbound?: OutboundObserver;
46
+ /**
47
+ * Optional attention gate (PR3, design §4.2). Resolved AFTER `onInbound`
48
+ * runs and BEFORE the runtime turn enqueues, so working memory / activity
49
+ * tracking still observe the message even when the gate skips the wake.
50
+ *
51
+ * Return `true` to wake the runtime, `false` to skip the turn. Errors are
52
+ * logged and treated as `true` (fail-open) so a buggy gate cannot silence
53
+ * the agent.
54
+ */
55
+ attentionGate?: (message: GatewayInboundMessage) => Promise<boolean> | boolean;
56
+ /**
57
+ * Resolve the hub URL the inbound message's agent is registered against.
58
+ * Threaded into `RuntimeRunOptions.hubUrl` so spawned CLI subprocesses
59
+ * target the correct hub. If unset, runtimes leave `BOTCORD_HUB`
60
+ * unspecified and fall back to whatever the bundled CLI defaults to.
61
+ */
62
+ resolveHubUrl?: (accountId: string) => string | undefined;
63
+ /**
64
+ * Optional NDJSON transcript writer. When provided, dispatcher emits one
65
+ * inbound record + one path record + (for dispatched turns) one terminal
66
+ * record per `handle()` call. A noop writer is used by default so existing
67
+ * call sites keep working unchanged. See `docs/transcript-logging.md`.
68
+ */
69
+ transcript?: TranscriptWriter;
45
70
  }
46
71
  /**
47
72
  * Gateway dispatcher: consumes `GatewayInboundEnvelope` and drives a runtime
@@ -65,6 +90,9 @@ export declare class Dispatcher {
65
90
  private readonly onOutbound?;
66
91
  private readonly composeUserTurn?;
67
92
  private readonly managedRoutes?;
93
+ private readonly attentionGate?;
94
+ private readonly resolveHubUrl?;
95
+ private readonly transcript;
68
96
  private readonly queues;
69
97
  constructor(opts: DispatcherOptions);
70
98
  /** Consume one inbound envelope, ack it once ownership is decided, then run its turn. */
@@ -74,7 +102,44 @@ export declare class Dispatcher {
74
102
  private safeAck;
75
103
  private getQueue;
76
104
  private runCancelPrevious;
105
+ /**
106
+ * Serial mode with coalesce-on-drain semantics:
107
+ *
108
+ * 1. First arrival on an idle queue boots the worker, which dispatches a
109
+ * single-message turn immediately (no batching delay).
110
+ * 2. Arrivals during an in-flight turn append to `serialBuffer`; when the
111
+ * worker finishes the current turn it drains the entire buffer and
112
+ * merges all pending entries into ONE next turn (folded into a single
113
+ * `raw.batch` so the composer renders them as multi-block input).
114
+ * 3. Buffer caps: at most `MAX_BATCH_BUFFER_ENTRIES` entries are retained
115
+ * (drop oldest) and merged turns are further trimmed to fit
116
+ * `MAX_BATCH_BUFFER_CHARS` of total raw text.
117
+ *
118
+ * Note: the pre-composed `text` from `handle()` is intentionally discarded
119
+ * here — at drain time the worker re-invokes `composeUserTurn` on the
120
+ * merged message so the runtime sees a single coherent prompt covering all
121
+ * coalesced messages.
122
+ */
77
123
  private runSerial;
124
+ /**
125
+ * Merge buffered serial entries into a single dispatchable unit. With one
126
+ * entry the call is a near no-op (just recompose). With ≥2 entries this
127
+ * flattens any per-entry `raw.batch` (the BotCord channel already groups
128
+ * one inbox-poll's worth of same-room/topic messages into a `raw.batch`),
129
+ * applies the `MAX_BATCH_BUFFER_CHARS` cap by dropping oldest individual
130
+ * messages, and then synthesizes a merged inbound message anchored on the
131
+ * latest entry's metadata (mentioned = OR across all entries).
132
+ */
133
+ private mergeSerialBuffer;
134
+ /**
135
+ * Re-run the user-turn composer at drain time. Mirrors the logic in
136
+ * `handle()` but operates on the (possibly merged) message. Falls back to
137
+ * raw trimmed text on composer failure so a buggy composer never drops a
138
+ * turn.
139
+ */
140
+ private recomposeUserTurn;
78
141
  private runTurn;
79
142
  private sendReply;
143
+ private emitInbound;
144
+ private emitOutbound;
80
145
  }