@botcord/daemon 0.2.4 → 0.2.5

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.
@@ -17,9 +17,9 @@ export interface DiscoveredAgentCredential {
17
17
  hubUrl: string;
18
18
  displayName?: string;
19
19
  /**
20
- * Runtime cached in the credentials file (docs/agent-runtime-property-plan.md).
21
- * Null for legacy bind-code credentials without the field; the daemon
22
- * falls back to `defaultRoute` in that case.
20
+ * Runtime cached in the credentials file. Null for legacy bind-code
21
+ * credentials without the field; the daemon falls back to `defaultRoute`
22
+ * in that case.
23
23
  */
24
24
  runtime?: string;
25
25
  /** Working directory cached alongside `runtime`. */
@@ -134,7 +134,7 @@ export function resolveBootAgents(cfg, opts = {}) {
134
134
  // Best-effort enrich with runtime/cwd cached in credentials. A missing
135
135
  // or unreadable file is not fatal — the gateway channel will surface the
136
136
  // real error at start. The fields we're after are purely for router
137
- // fallback (docs/agent-runtime-property-plan.md §4.3).
137
+ // fallback.
138
138
  const agents = explicit.map((agentId) => {
139
139
  const credentialsFile = defaultCredentialsFile(agentId);
140
140
  const entry = {
@@ -7,8 +7,6 @@
7
7
  * codex-home/ — per-agent CODEX_HOME used by the codex adapter so codex
8
8
  * reads a daemon-written AGENTS.md (systemContext carrier)
9
9
  * and stores its sessions/ without touching ~/.codex.
10
- *
11
- * See docs/daemon-agent-workspace-plan.md §4 for the full layout rationale.
12
10
  */
13
11
  import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, readlinkSync, symlinkSync, unlinkSync, writeFileSync, } from "node:fs";
14
12
  import { homedir } from "node:os";
@@ -5,16 +5,13 @@
5
5
  * Independent from the agent data-plane WS: different auth (user access
6
6
  * token vs agent JWT), different endpoint (`/daemon/ws`), different
7
7
  * lifecycle (alive even when zero agents are bound).
8
- *
9
- * See `docs/daemon-control-plane-plan.md` §4.1, §4.3, §8.
10
8
  */
11
9
  import WebSocket from "ws";
12
10
  import { type ControlAck, type ControlFrame } from "@botcord/protocol-core";
13
11
  import { type UserAuthManager } from "./user-auth.js";
14
12
  /**
15
13
  * Build the canonical signing input for a control frame: RFC 8785 (JCS)
16
- * canonicalization of `{id, type, params, ts}`. Per
17
- * `docs/daemon-control-plane-api-contract.md` §3.3 — the Hub uses Python
14
+ * canonicalization of `{id, type, params, ts}`. The Hub uses Python
18
15
  * `jcs.canonicalize` over the same object before signing.
19
16
  *
20
17
  * Excludes `sig` by definition. `params` defaults to `{}` (empty object)
@@ -5,8 +5,6 @@
5
5
  * Independent from the agent data-plane WS: different auth (user access
6
6
  * token vs agent JWT), different endpoint (`/daemon/ws`), different
7
7
  * lifecycle (alive even when zero agents are bound).
8
- *
9
- * See `docs/daemon-control-plane-plan.md` §4.1, §4.3, §8.
10
8
  */
11
9
  import WebSocket from "ws";
12
10
  import { buildDaemonWebSocketUrl, CONTROL_FRAME_TYPES, jcsCanonicalize, resolveHubControlPublicKey, verifyEd25519, } from "@botcord/protocol-core";
@@ -18,8 +16,7 @@ const KEEPALIVE_INTERVAL_MS = 25_000;
18
16
  const REPLAY_DEDUPE_CAP = 256;
19
17
  /**
20
18
  * Build the canonical signing input for a control frame: RFC 8785 (JCS)
21
- * canonicalization of `{id, type, params, ts}`. Per
22
- * `docs/daemon-control-plane-api-contract.md` §3.3 — the Hub uses Python
19
+ * canonicalization of `{id, type, params, ts}`. The Hub uses Python
23
20
  * `jcs.canonicalize` over the same object before signing.
24
21
  *
25
22
  * Excludes `sig` by definition. `params` defaults to `{}` (empty object)
@@ -9,9 +9,8 @@ export interface ToGatewayConfigOptions {
9
9
  */
10
10
  agentIds?: string[];
11
11
  /**
12
- * Per-agent runtime/cwd cached from credentials (see
13
- * `docs/agent-runtime-property-plan.md`). When present for an agent id,
14
- * `toGatewayConfig` synthesizes a terminal route pinning that agent's
12
+ * Per-agent runtime/cwd cached from credentials. When present for an agent
13
+ * id, `toGatewayConfig` synthesizes a terminal route pinning that agent's
15
14
  * turns to its runtime. Explicit `cfg.routes` entries still win because
16
15
  * synthesized routes are appended after them.
17
16
  */
@@ -74,7 +74,42 @@ export declare class Dispatcher {
74
74
  private safeAck;
75
75
  private getQueue;
76
76
  private runCancelPrevious;
77
+ /**
78
+ * Serial mode with coalesce-on-drain semantics:
79
+ *
80
+ * 1. First arrival on an idle queue boots the worker, which dispatches a
81
+ * single-message turn immediately (no batching delay).
82
+ * 2. Arrivals during an in-flight turn append to `serialBuffer`; when the
83
+ * worker finishes the current turn it drains the entire buffer and
84
+ * merges all pending entries into ONE next turn (folded into a single
85
+ * `raw.batch` so the composer renders them as multi-block input).
86
+ * 3. Buffer caps: at most `MAX_BATCH_BUFFER_ENTRIES` entries are retained
87
+ * (drop oldest) and merged turns are further trimmed to fit
88
+ * `MAX_BATCH_BUFFER_CHARS` of total raw text.
89
+ *
90
+ * Note: the pre-composed `text` from `handle()` is intentionally discarded
91
+ * here — at drain time the worker re-invokes `composeUserTurn` on the
92
+ * merged message so the runtime sees a single coherent prompt covering all
93
+ * coalesced messages.
94
+ */
77
95
  private runSerial;
96
+ /**
97
+ * Merge buffered serial entries into a single dispatchable unit. With one
98
+ * entry the call is a near no-op (just recompose). With ≥2 entries this
99
+ * flattens any per-entry `raw.batch` (the BotCord channel already groups
100
+ * one inbox-poll's worth of same-room/topic messages into a `raw.batch`),
101
+ * applies the `MAX_BATCH_BUFFER_CHARS` cap by dropping oldest individual
102
+ * messages, and then synthesizes a merged inbound message anchored on the
103
+ * latest entry's metadata (mentioned = OR across all entries).
104
+ */
105
+ private mergeSerialBuffer;
106
+ /**
107
+ * Re-run the user-turn composer at drain time. Mirrors the logic in
108
+ * `handle()` but operates on the (possibly merged) message. Falls back to
109
+ * raw trimmed text on composer failure so a buggy composer never drops a
110
+ * turn.
111
+ */
112
+ private recomposeUserTurn;
78
113
  private runTurn;
79
114
  private sendReply;
80
115
  }
@@ -1,6 +1,21 @@
1
1
  import { resolveRoute } from "./router.js";
2
2
  import { sessionKey } from "./session-store.js";
3
3
  const DEFAULT_TURN_TIMEOUT_MS = 10 * 60 * 1000;
4
+ /**
5
+ * Owner-chat room prefix. Reply-text gating: only rooms with this prefix get
6
+ * `result.text` forwarded to the channel; in every other room the runtime's
7
+ * plain text output is discarded — agents must use the `botcord_send` tool
8
+ * (or `botcord send` CLI via Bash) to actually deliver replies.
9
+ */
10
+ const OWNER_CHAT_ROOM_PREFIX = "rm_oc_";
11
+ /** Maximum number of buffered serial entries per queue. Excess entries drop oldest. */
12
+ const MAX_BATCH_BUFFER_ENTRIES = 40;
13
+ /**
14
+ * Soft cap on the total characters across raw.batch members in a merged
15
+ * turn. When exceeded, oldest entries are dropped (with a warn log) so the
16
+ * runtime prompt stays bounded even if the channel-side batch was huge.
17
+ */
18
+ const MAX_BATCH_BUFFER_CHARS = 16000;
4
19
  /**
5
20
  * Gateway dispatcher: consumes `GatewayInboundEnvelope` and drives a runtime
6
21
  * turn per message, respecting queue mode, trust level, streaming, and
@@ -55,12 +70,17 @@ export class Dispatcher {
55
70
  await this.safeAck(envelope);
56
71
  return;
57
72
  }
58
- // Compose the final user-turn text. The composer can enrich the raw
59
- // message with sender label, room header, NO_REPLY hint, etc. — anything
60
- // that should land in the runtime transcript. Failures fall back to the
61
- // raw trimmed text so a buggy composer cannot drop turns.
73
+ const managed = this.managedRoutes ? Array.from(this.managedRoutes.values()) : undefined;
74
+ const route = resolveRoute(msg, this.config, managed);
75
+ const mode = resolveQueueMode(route, msg.conversation.kind);
76
+ const queueKey = buildQueueKey(msg);
77
+ // Compose the final user-turn text only for cancel-previous mode, where
78
+ // the dispatcher consumes the pre-composed text directly. Serial mode
79
+ // re-runs the composer at drain time on the merged message (so it sees
80
+ // the full coalesced batch instead of any single arrival), so calling
81
+ // the composer here would just be redundant work.
62
82
  let text = rawText;
63
- if (this.composeUserTurn) {
83
+ if (mode === "cancel-previous" && this.composeUserTurn) {
64
84
  try {
65
85
  const composed = this.composeUserTurn(msg);
66
86
  if (typeof composed === "string" && composed.length > 0) {
@@ -74,10 +94,6 @@ export class Dispatcher {
74
94
  });
75
95
  }
76
96
  }
77
- const managed = this.managedRoutes ? Array.from(this.managedRoutes.values()) : undefined;
78
- const route = resolveRoute(msg, this.config, managed);
79
- const mode = resolveQueueMode(route, msg.conversation.kind);
80
- const queueKey = buildQueueKey(msg);
81
97
  // Ack immediately: once the dispatcher has a route + queue key, ownership is decided.
82
98
  await this.safeAck(envelope);
83
99
  // Notify the optional observer (activity tracking, metrics, etc.) as soon
@@ -139,8 +155,9 @@ export class Dispatcher {
139
155
  if (!q) {
140
156
  q = {
141
157
  current: null,
142
- tail: Promise.resolve(),
143
158
  cancelGen: 0,
159
+ serialBuffer: [],
160
+ serialWorkerActive: false,
144
161
  };
145
162
  this.queues.set(key, q);
146
163
  }
@@ -169,12 +186,145 @@ export class Dispatcher {
169
186
  }
170
187
  await this.runTurn(queueKey, route, text, msg, channel);
171
188
  }
172
- async runSerial(queueKey, route, text, msg, channel) {
189
+ /**
190
+ * Serial mode with coalesce-on-drain semantics:
191
+ *
192
+ * 1. First arrival on an idle queue boots the worker, which dispatches a
193
+ * single-message turn immediately (no batching delay).
194
+ * 2. Arrivals during an in-flight turn append to `serialBuffer`; when the
195
+ * worker finishes the current turn it drains the entire buffer and
196
+ * merges all pending entries into ONE next turn (folded into a single
197
+ * `raw.batch` so the composer renders them as multi-block input).
198
+ * 3. Buffer caps: at most `MAX_BATCH_BUFFER_ENTRIES` entries are retained
199
+ * (drop oldest) and merged turns are further trimmed to fit
200
+ * `MAX_BATCH_BUFFER_CHARS` of total raw text.
201
+ *
202
+ * Note: the pre-composed `text` from `handle()` is intentionally discarded
203
+ * here — at drain time the worker re-invokes `composeUserTurn` on the
204
+ * merged message so the runtime sees a single coherent prompt covering all
205
+ * coalesced messages.
206
+ */
207
+ async runSerial(queueKey, route, _text, msg, channel) {
173
208
  const q = this.getQueue(queueKey);
174
- const prev = q.tail;
175
- const next = prev.then(() => this.runTurn(queueKey, route, text, msg, channel));
176
- q.tail = next.catch(() => undefined);
177
- return next;
209
+ q.serialBuffer.push({ route, msg, channel });
210
+ while (q.serialBuffer.length > MAX_BATCH_BUFFER_ENTRIES) {
211
+ const dropped = q.serialBuffer.shift();
212
+ this.log.warn("dispatcher: serial buffer overflow — dropped oldest entry", {
213
+ queueKey,
214
+ droppedMessageId: dropped.msg.id,
215
+ bufferCap: MAX_BATCH_BUFFER_ENTRIES,
216
+ });
217
+ }
218
+ if (q.serialWorkerActive)
219
+ return;
220
+ q.serialWorkerActive = true;
221
+ try {
222
+ while (q.serialBuffer.length > 0) {
223
+ const drained = q.serialBuffer.splice(0, q.serialBuffer.length);
224
+ const merged = this.mergeSerialBuffer(drained, queueKey);
225
+ if (!merged)
226
+ continue;
227
+ await this.runTurn(queueKey, merged.route, merged.text, merged.msg, merged.channel);
228
+ }
229
+ }
230
+ finally {
231
+ q.serialWorkerActive = false;
232
+ }
233
+ }
234
+ /**
235
+ * Merge buffered serial entries into a single dispatchable unit. With one
236
+ * entry the call is a near no-op (just recompose). With ≥2 entries this
237
+ * flattens any per-entry `raw.batch` (the BotCord channel already groups
238
+ * one inbox-poll's worth of same-room/topic messages into a `raw.batch`),
239
+ * applies the `MAX_BATCH_BUFFER_CHARS` cap by dropping oldest individual
240
+ * messages, and then synthesizes a merged inbound message anchored on the
241
+ * latest entry's metadata (mentioned = OR across all entries).
242
+ */
243
+ mergeSerialBuffer(entries, queueKey) {
244
+ if (entries.length === 0)
245
+ return null;
246
+ if (entries.length === 1) {
247
+ const only = entries[0];
248
+ return {
249
+ route: only.route,
250
+ text: this.recomposeUserTurn(only.msg),
251
+ msg: only.msg,
252
+ channel: only.channel,
253
+ };
254
+ }
255
+ // Flatten: each entry's raw may already be a BatchedInboxRaw with
256
+ // `.batch`; otherwise it's a single InboxMessage we treat as a 1-element
257
+ // batch. Insertion order preserves chronology.
258
+ const items = [];
259
+ for (const e of entries) {
260
+ const raw = e.msg.raw;
261
+ const batch = raw && Array.isArray(raw.batch)
262
+ ? (raw.batch)
263
+ : null;
264
+ if (batch) {
265
+ for (const m of batch)
266
+ items.push(m);
267
+ }
268
+ else if (raw) {
269
+ items.push(raw);
270
+ }
271
+ }
272
+ // Char-cap: drop oldest until we fit. Reserve at least one item so we
273
+ // never produce an empty merged batch.
274
+ let totalChars = items.reduce((acc, m) => acc + (typeof m?.text === "string" ? m.text.length : 0), 0);
275
+ let droppedCount = 0;
276
+ while (totalChars > MAX_BATCH_BUFFER_CHARS && items.length > 1) {
277
+ const removed = items.shift();
278
+ totalChars -= typeof removed?.text === "string" ? removed.text.length : 0;
279
+ droppedCount += 1;
280
+ }
281
+ if (droppedCount > 0) {
282
+ this.log.warn("dispatcher: merged batch exceeded char cap — dropped oldest", {
283
+ queueKey,
284
+ droppedCount,
285
+ remaining: items.length,
286
+ totalChars,
287
+ charCap: MAX_BATCH_BUFFER_CHARS,
288
+ });
289
+ }
290
+ const latest = entries[entries.length - 1];
291
+ const latestRaw = latest.msg.raw ?? {};
292
+ const mergedRaw = { ...latestRaw, batch: items };
293
+ const anyMentioned = entries.some((e) => e.msg.mentioned === true);
294
+ const mergedMsg = {
295
+ ...latest.msg,
296
+ mentioned: anyMentioned,
297
+ raw: mergedRaw,
298
+ };
299
+ return {
300
+ route: latest.route,
301
+ text: this.recomposeUserTurn(mergedMsg),
302
+ msg: mergedMsg,
303
+ channel: latest.channel,
304
+ };
305
+ }
306
+ /**
307
+ * Re-run the user-turn composer at drain time. Mirrors the logic in
308
+ * `handle()` but operates on the (possibly merged) message. Falls back to
309
+ * raw trimmed text on composer failure so a buggy composer never drops a
310
+ * turn.
311
+ */
312
+ recomposeUserTurn(msg) {
313
+ const rawText = typeof msg.text === "string" ? msg.text.trim() : "";
314
+ if (!this.composeUserTurn)
315
+ return rawText;
316
+ try {
317
+ const composed = this.composeUserTurn(msg);
318
+ if (typeof composed === "string" && composed.length > 0)
319
+ return composed;
320
+ }
321
+ catch (err) {
322
+ this.log.warn("dispatcher: composeUserTurn (drain) threw — using raw text", {
323
+ messageId: msg.id,
324
+ error: err instanceof Error ? err.message : String(err),
325
+ });
326
+ }
327
+ return rawText;
178
328
  }
179
329
  async runTurn(queueKey, route, text, msg, channel) {
180
330
  const q = this.getQueue(queueKey);
@@ -290,16 +440,42 @@ export class Dispatcher {
290
440
  if (controller.signal.aborted && !slot.timedOut) {
291
441
  return;
292
442
  }
443
+ // Reply gating: only owner-chat rooms accept the runtime's plain text
444
+ // output as a delivered message. Every other room expects the agent to
445
+ // call the `botcord_send` tool (or `botcord send` CLI via Bash)
446
+ // explicitly; runtime text in those rooms is logged and dropped,
447
+ // including timeout / error notifications.
448
+ //
449
+ // Owner-chat is identified by either the `rm_oc_` room prefix OR
450
+ // `source_type === "dashboard_user_chat"` on the raw envelope — the
451
+ // same dual check used by `sender-classify.ts:classifyActivitySender`,
452
+ // so the dispatcher's reply gating stays in lock-step with the
453
+ // composer's owner-bypass.
454
+ //
455
+ // Side effect: `onOutbound` (loop-risk tracking) only fires when a
456
+ // reply actually leaves the dispatcher. In non-owner-chat rooms the
457
+ // expectation is that the agent's `botcord_send` tool calls do their
458
+ // own loop-risk accounting downstream.
459
+ const isOwnerChat = isOwnerChatRoom(msg);
293
460
  if (slot.timedOut) {
294
- await this.sendReply(channel, {
295
- channel: msg.channel,
296
- accountId: msg.accountId,
297
- conversationId: msg.conversation.id,
298
- threadId: msg.conversation.threadId ?? null,
299
- text: `⚠️ Runtime timeout after ${Math.round(this.turnTimeoutMs / 60000)} minute(s); aborted`,
300
- replyTo: msg.id,
301
- traceId: msg.trace?.id ?? null,
302
- });
461
+ if (isOwnerChat) {
462
+ await this.sendReply(channel, {
463
+ channel: msg.channel,
464
+ accountId: msg.accountId,
465
+ conversationId: msg.conversation.id,
466
+ threadId: msg.conversation.threadId ?? null,
467
+ text: `⚠️ Runtime timeout after ${Math.round(this.turnTimeoutMs / 60000)} minute(s); aborted`,
468
+ replyTo: msg.id,
469
+ traceId: msg.trace?.id ?? null,
470
+ });
471
+ }
472
+ else {
473
+ this.log.warn("dispatcher: timeout in non-owner-chat room — error reply suppressed", {
474
+ queueKey,
475
+ conversationId: msg.conversation.id,
476
+ timeoutMs: this.turnTimeoutMs,
477
+ });
478
+ }
303
479
  return;
304
480
  }
305
481
  if (threw) {
@@ -308,16 +484,24 @@ export class Dispatcher {
308
484
  runtime: route.runtime,
309
485
  error: threw instanceof Error ? threw.message : String(threw),
310
486
  });
311
- const shortMsg = threw instanceof Error ? threw.message : String(threw);
312
- await this.sendReply(channel, {
313
- channel: msg.channel,
314
- accountId: msg.accountId,
315
- conversationId: msg.conversation.id,
316
- threadId: msg.conversation.threadId ?? null,
317
- text: `⚠️ Runtime error: ${truncate(shortMsg, 500)}`,
318
- replyTo: msg.id,
319
- traceId: msg.trace?.id ?? null,
320
- });
487
+ if (isOwnerChat) {
488
+ const shortMsg = threw instanceof Error ? threw.message : String(threw);
489
+ await this.sendReply(channel, {
490
+ channel: msg.channel,
491
+ accountId: msg.accountId,
492
+ conversationId: msg.conversation.id,
493
+ threadId: msg.conversation.threadId ?? null,
494
+ text: `⚠️ Runtime error: ${truncate(shortMsg, 500)}`,
495
+ replyTo: msg.id,
496
+ traceId: msg.trace?.id ?? null,
497
+ });
498
+ }
499
+ else {
500
+ this.log.warn("dispatcher: runtime error in non-owner-chat room — error reply suppressed", {
501
+ queueKey,
502
+ conversationId: msg.conversation.id,
503
+ });
504
+ }
321
505
  return;
322
506
  }
323
507
  if (!result)
@@ -379,6 +563,18 @@ export class Dispatcher {
379
563
  const replyText = (result.text || "").trim();
380
564
  if (!replyText)
381
565
  return;
566
+ if (!isOwnerChat) {
567
+ // Non-owner-chat rooms: result.text never goes out. The agent is
568
+ // expected to have used the `botcord_send` tool / `botcord send` CLI
569
+ // already; whatever it left in the runtime's final assistant text is
570
+ // discarded so it doesn't leak into the room.
571
+ this.log.debug("dispatcher: non-owner-chat — discarding result.text (agent must use botcord_send)", {
572
+ queueKey,
573
+ conversationId: msg.conversation.id,
574
+ replyTextLen: replyText.length,
575
+ });
576
+ return;
577
+ }
382
578
  // One last abort check immediately before the send. Narrows the window
383
579
  // in which a cancel-previous arriving during session-store.set could
384
580
  // still slip a stale reply past us.
@@ -435,6 +631,29 @@ function buildQueueKey(msg) {
435
631
  const thread = msg.conversation.threadId ?? "";
436
632
  return `${msg.channel}:${msg.accountId}:${msg.conversation.id}:${thread}`;
437
633
  }
634
+ /**
635
+ * Owner-chat predicate used by the dispatcher's reply gating. Matches the
636
+ * dual check in `sender-classify.ts:classifyActivitySender` so the
637
+ * dispatcher's gate stays consistent with the composer's owner-bypass:
638
+ *
639
+ * 1. `rm_oc_*` room id, OR
640
+ * 2. `source_type === "dashboard_user_chat"` on the raw envelope.
641
+ *
642
+ * The latter exists because the dashboard's user-chat surface can route
643
+ * messages through non-`rm_oc_` rooms in some flows; treating them as
644
+ * owner-trust here keeps the agent's plain reply text reachable.
645
+ */
646
+ function isOwnerChatRoom(msg) {
647
+ if (msg.conversation.id.startsWith(OWNER_CHAT_ROOM_PREFIX))
648
+ return true;
649
+ const raw = msg.raw;
650
+ if (raw && typeof raw === "object") {
651
+ const sourceType = raw.source_type;
652
+ if (sourceType === "dashboard_user_chat")
653
+ return true;
654
+ }
655
+ return false;
656
+ }
438
657
  function resolveQueueMode(route, kind) {
439
658
  if (route.queueMode)
440
659
  return route.queueMode;
@@ -52,8 +52,7 @@ export declare function reloadConfig(ctx: {
52
52
  gateway: Gateway;
53
53
  }): Promise<ReloadResult>;
54
54
  /**
55
- * Per-agent entry returned by `list_agents`. Shape follows
56
- * `docs/daemon-control-plane-api-contract.md` §3.2 — `{id, name, online}`.
55
+ * Per-agent entry returned by `list_agents`. Wire shape: `{id, name, online}`.
57
56
  * `status` and `lastMessageAt` are extra daemon-only fields the dashboard
58
57
  * may ignore; kept so future contract revisions can promote them without
59
58
  * breaking the wire.
package/dist/provision.js CHANGED
@@ -3,8 +3,6 @@
3
3
  * to this module with the parsed {@link ControlFrame}; we execute the
4
4
  * side effects (register agent, write credentials, load route, add/remove
5
5
  * gateway channel) and return an ack payload.
6
- *
7
- * See `docs/daemon-control-plane-plan.md` §4.3, §5.3, §8.
8
6
  */
9
7
  import { existsSync, rmSync, unlinkSync } from "node:fs";
10
8
  import { homedir } from "node:os";
@@ -222,9 +220,9 @@ async function provisionAgent(params, ctx) {
222
220
  };
223
221
  }
224
222
  async function materializeCredentials(params, cfg, ctx, explicitCwd) {
225
- // Runtime is an agent property (docs/agent-runtime-property-plan.md §4.1).
226
- // Hub is authoritative; top-level `runtime` wins, `adapter` is a one-release
227
- // alias, and `credentials.runtime` is the per-agent cached copy.
223
+ // Runtime is an agent property. Hub is authoritative; top-level `runtime`
224
+ // wins, `adapter` is a one-release alias, and `credentials.runtime` is the
225
+ // per-agent cached copy.
228
226
  const runtime = pickRuntime(params);
229
227
  if (runtime)
230
228
  assertKnownRuntime(runtime);
package/dist/turn-text.js CHANGED
@@ -4,6 +4,16 @@ const GROUP_HINT = '[In group chats, do NOT reply unless you are explicitly ment
4
4
  'If no response is needed, reply with exactly "NO_REPLY" and nothing else.]';
5
5
  const DIRECT_HINT = '[If the conversation has naturally concluded or no response is needed, ' +
6
6
  'reply with exactly "NO_REPLY" and nothing else.]';
7
+ /**
8
+ * Reminder appended to every wrapped (non-owner-chat) inbound message. The
9
+ * dispatcher discards `result.text` for any room that is not `rm_oc_*`, so
10
+ * the agent must call the `botcord_send` tool (or the `botcord send` CLI
11
+ * via Bash) to actually deliver a reply. Plain assistant text in those
12
+ * rooms is logged and dropped.
13
+ */
14
+ const NON_OWNER_REPLY_HINT = "[This room is NOT owner-chat. Plain text output WILL NOT be sent. " +
15
+ "To reply, call the `botcord_send` tool, or run " +
16
+ '`botcord send --room <room_id> --text "..."` via Bash.]';
7
17
  /**
8
18
  * Read the BotCord envelope type from a raw inbound message. Returns
9
19
  * `undefined` when the message didn't come from the BotCord channel or the
@@ -116,6 +126,8 @@ export function composeBotCordUserTurn(msg) {
116
126
  `</${tag}>`,
117
127
  "",
118
128
  hint,
129
+ "",
130
+ NON_OWNER_REPLY_HINT,
119
131
  ];
120
132
  if (contactRequestHint) {
121
133
  lines.push("", contactRequestHint);
@@ -160,7 +172,14 @@ function composeBatchedTurn(msg, batch) {
160
172
  }
161
173
  }
162
174
  const hint = isGroup ? GROUP_HINT : DIRECT_HINT;
163
- const lines = [header.join(" | "), blocks.join("\n"), "", hint];
175
+ const lines = [
176
+ header.join(" | "),
177
+ blocks.join("\n"),
178
+ "",
179
+ hint,
180
+ "",
181
+ NON_OWNER_REPLY_HINT,
182
+ ];
164
183
  if (contactRequestSenders.length > 0) {
165
184
  // Dedup + list — multiple distinct senders show as "A, B".
166
185
  const unique = Array.from(new Set(contactRequestSenders));
package/dist/user-auth.js CHANGED
@@ -5,8 +5,6 @@
5
5
  * `~/.botcord/credentials/*.json`), the user-auth record is singular —
6
6
  * the daemon only logs in as *one* user at a time. Stored at
7
7
  * `~/.botcord/daemon/user-auth.json` with `0600` permissions.
8
- *
9
- * See `docs/daemon-control-plane-plan.md` §6–§7.
10
8
  */
11
9
  import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
12
10
  import path from "node:path";
@@ -2,7 +2,7 @@
2
2
  * Working memory — persistent, account-scoped notes injected into every turn.
3
3
  *
4
4
  * Stored at `~/.botcord/agents/{agentId}/state/working-memory.json` (the
5
- * per-agent state dir owned by the daemon; see docs/daemon-agent-workspace-plan.md §8).
5
+ * per-agent state dir owned by the daemon).
6
6
  *
7
7
  * Ported from plugin/src/memory.ts (dropping workspace + OpenClaw runtime
8
8
  * branches) and plugin/src/memory-protocol.ts (prompt builder).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botcord/daemon",
3
- "version": "0.2.4",
3
+ "version": "0.2.5",
4
4
  "description": "BotCord local daemon — bridges Hub inbox push to local Claude Code / Codex / Gemini CLIs",
5
5
  "type": "module",
6
6
  "bin": {
@@ -31,9 +31,9 @@ export interface DiscoveredAgentCredential {
31
31
  hubUrl: string;
32
32
  displayName?: string;
33
33
  /**
34
- * Runtime cached in the credentials file (docs/agent-runtime-property-plan.md).
35
- * Null for legacy bind-code credentials without the field; the daemon
36
- * falls back to `defaultRoute` in that case.
34
+ * Runtime cached in the credentials file. Null for legacy bind-code
35
+ * credentials without the field; the daemon falls back to `defaultRoute`
36
+ * in that case.
37
37
  */
38
38
  runtime?: string;
39
39
  /** Working directory cached alongside `runtime`. */
@@ -221,7 +221,7 @@ export function resolveBootAgents(
221
221
  // Best-effort enrich with runtime/cwd cached in credentials. A missing
222
222
  // or unreadable file is not fatal — the gateway channel will surface the
223
223
  // real error at start. The fields we're after are purely for router
224
- // fallback (docs/agent-runtime-property-plan.md §4.3).
224
+ // fallback.
225
225
  const agents: DiscoveredAgentCredential[] = explicit.map((agentId) => {
226
226
  const credentialsFile = defaultCredentialsFile(agentId);
227
227
  const entry: DiscoveredAgentCredential = {
@@ -7,8 +7,6 @@
7
7
  * codex-home/ — per-agent CODEX_HOME used by the codex adapter so codex
8
8
  * reads a daemon-written AGENTS.md (systemContext carrier)
9
9
  * and stores its sessions/ without touching ~/.codex.
10
- *
11
- * See docs/daemon-agent-workspace-plan.md §4 for the full layout rationale.
12
10
  */
13
11
  import {
14
12
  chmodSync,
@@ -5,8 +5,6 @@
5
5
  * Independent from the agent data-plane WS: different auth (user access
6
6
  * token vs agent JWT), different endpoint (`/daemon/ws`), different
7
7
  * lifecycle (alive even when zero agents are bound).
8
- *
9
- * See `docs/daemon-control-plane-plan.md` §4.1, §4.3, §8.
10
8
  */
11
9
  import WebSocket from "ws";
12
10
  import {
@@ -31,8 +29,7 @@ const REPLAY_DEDUPE_CAP = 256;
31
29
 
32
30
  /**
33
31
  * Build the canonical signing input for a control frame: RFC 8785 (JCS)
34
- * canonicalization of `{id, type, params, ts}`. Per
35
- * `docs/daemon-control-plane-api-contract.md` §3.3 — the Hub uses Python
32
+ * canonicalization of `{id, type, params, ts}`. The Hub uses Python
36
33
  * `jcs.canonicalize` over the same object before signing.
37
34
  *
38
35
  * Excludes `sig` by definition. `params` defaults to `{}` (empty object)
@@ -19,9 +19,8 @@ export interface ToGatewayConfigOptions {
19
19
  */
20
20
  agentIds?: string[];
21
21
  /**
22
- * Per-agent runtime/cwd cached from credentials (see
23
- * `docs/agent-runtime-property-plan.md`). When present for an agent id,
24
- * `toGatewayConfig` synthesizes a terminal route pinning that agent's
22
+ * Per-agent runtime/cwd cached from credentials. When present for an agent
23
+ * id, `toGatewayConfig` synthesizes a terminal route pinning that agent's
25
24
  * turns to its runtime. Explicit `cfg.routes` entries still win because
26
25
  * synthesized routes are appended after them.
27
26
  */