@botcord/daemon 0.2.3 → 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.
@@ -664,6 +664,7 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
664
664
  try {
665
665
  const token = await client.ensureToken();
666
666
  const block = ctx.block as { raw?: unknown; kind?: string; seq?: number } | undefined;
667
+ const seq = typeof block?.seq === "number" ? block.seq : 0;
667
668
  const resp = await fetch(`${hubUrl}/hub/stream-block`, {
668
669
  method: "POST",
669
670
  headers: {
@@ -672,8 +673,8 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
672
673
  },
673
674
  body: JSON.stringify({
674
675
  trace_id: ctx.traceId,
675
- seq: typeof block?.seq === "number" ? block.seq : 0,
676
- block: ctx.block,
676
+ seq,
677
+ block: normalizeBlockForHub(block, seq),
677
678
  }),
678
679
  signal: AbortSignal.timeout(10_000),
679
680
  });
@@ -697,5 +698,90 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
697
698
  return adapter;
698
699
  }
699
700
 
700
- // Re-export the normalizer for tests that want to exercise it directly.
701
+ // Re-export the normalizers for tests that want to exercise them directly.
701
702
  export { normalizeInbox as __normalizeInboxForTests };
703
+ export { normalizeBlockForHub as __normalizeBlockForHubForTests };
704
+
705
+ /**
706
+ * Reshape a runtime StreamBlock `{ raw, kind, seq }` into the
707
+ * `{ kind, payload, seq }` form the owner-chat frontend renders.
708
+ *
709
+ * Daemon-internal kinds are Claude Code / Codex specific; the dashboard's
710
+ * StreamBlocksView expects a smaller vocabulary (`assistant`, `tool_call`,
711
+ * `tool_result`, `reasoning`) with structured `payload` fields. Without this
712
+ * remap the UI falls back to printing the bare kind string per step, which
713
+ * is what users see as "system / assistant_text / other / other".
714
+ *
715
+ * Extraction is best-effort — unknown shapes pass through as `other` with
716
+ * an empty payload rather than throwing.
717
+ */
718
+ function normalizeBlockForHub(
719
+ block: { raw?: unknown; kind?: string; seq?: number } | undefined,
720
+ seq: number,
721
+ ): { kind: string; seq: number; payload: Record<string, unknown> } {
722
+ const raw = (block?.raw ?? {}) as any;
723
+ const kind = block?.kind ?? "other";
724
+ const payload: Record<string, unknown> = {};
725
+
726
+ if (kind === "assistant_text") {
727
+ // Claude Code: {type:"assistant", message:{content:[{type:"text",text}]}}
728
+ // Codex: {type:"item.completed", item:{type:"agent_message", text}}
729
+ let text = "";
730
+ const contents = Array.isArray(raw?.message?.content) ? raw.message.content : [];
731
+ for (const c of contents) {
732
+ if (c?.type === "text" && typeof c.text === "string") text += c.text;
733
+ }
734
+ if (!text && typeof raw?.item?.text === "string") text = raw.item.text;
735
+ return { kind: "assistant", seq, payload: { text } };
736
+ }
737
+
738
+ if (kind === "tool_use") {
739
+ // Claude Code: assistant message w/ content[].type === "tool_use" → {id,name,input}
740
+ // Codex: item.started / item.completed for command_execution, file_change, mcp_tool_call, web_search
741
+ const contents = Array.isArray(raw?.message?.content) ? raw.message.content : [];
742
+ const tu = contents.find((c: any) => c?.type === "tool_use");
743
+ if (tu) {
744
+ payload.name = typeof tu.name === "string" ? tu.name : "tool";
745
+ if (tu.input && typeof tu.input === "object") payload.params = tu.input;
746
+ if (typeof tu.id === "string") payload.id = tu.id;
747
+ } else if (raw?.item && typeof raw.item === "object") {
748
+ payload.name = typeof raw.item.type === "string" ? raw.item.type : "tool";
749
+ payload.params = raw.item;
750
+ }
751
+ return { kind: "tool_call", seq, payload };
752
+ }
753
+
754
+ if (kind === "tool_result") {
755
+ // Claude Code: {type:"user", message:{content:[{type:"tool_result",tool_use_id,content}]}}
756
+ const contents = Array.isArray(raw?.message?.content) ? raw.message.content : [];
757
+ const tr = contents.find((c: any) => c?.type === "tool_result");
758
+ if (tr) {
759
+ let resultStr = "";
760
+ if (typeof tr.content === "string") {
761
+ resultStr = tr.content;
762
+ } else if (Array.isArray(tr.content)) {
763
+ resultStr = tr.content
764
+ .map((c: any) => (typeof c?.text === "string" ? c.text : JSON.stringify(c)))
765
+ .join("\n");
766
+ }
767
+ payload.result = resultStr;
768
+ if (typeof tr.tool_use_id === "string") payload.tool_use_id = tr.tool_use_id;
769
+ }
770
+ return { kind: "tool_result", seq, payload };
771
+ }
772
+
773
+ if (kind === "system") {
774
+ if (typeof raw?.subtype === "string") payload.subtype = raw.subtype;
775
+ if (typeof raw?.session_id === "string") payload.session_id = raw.session_id;
776
+ if (typeof raw?.model === "string") payload.model = raw.model;
777
+ return { kind: "system", seq, payload };
778
+ }
779
+
780
+ // "other" — e.g. Claude Code `type:"result"` end-of-turn summary.
781
+ if (raw?.type === "result") {
782
+ if (typeof raw.result === "string") payload.text = raw.result;
783
+ if (typeof raw.subtype === "string") payload.subtype = raw.subtype;
784
+ if (typeof raw.total_cost_usd === "number") payload.total_cost_usd = raw.total_cost_usd;
785
+ }
786
+ return { kind: "other", seq, payload };
787
+ }
@@ -20,6 +20,24 @@ import type {
20
20
 
21
21
  const DEFAULT_TURN_TIMEOUT_MS = 10 * 60 * 1000;
22
22
 
23
+ /**
24
+ * Owner-chat room prefix. Reply-text gating: only rooms with this prefix get
25
+ * `result.text` forwarded to the channel; in every other room the runtime's
26
+ * plain text output is discarded — agents must use the `botcord_send` tool
27
+ * (or `botcord send` CLI via Bash) to actually deliver replies.
28
+ */
29
+ const OWNER_CHAT_ROOM_PREFIX = "rm_oc_";
30
+
31
+ /** Maximum number of buffered serial entries per queue. Excess entries drop oldest. */
32
+ const MAX_BATCH_BUFFER_ENTRIES = 40;
33
+
34
+ /**
35
+ * Soft cap on the total characters across raw.batch members in a merged
36
+ * turn. When exceeded, oldest entries are dropped (with a warn log) so the
37
+ * runtime prompt stays bounded even if the channel-side batch was huge.
38
+ */
39
+ const MAX_BATCH_BUFFER_CHARS = 16000;
40
+
23
41
  /** Factory signature for building a runtime adapter at turn dispatch time. */
24
42
  export type RuntimeFactory = (
25
43
  runtimeId: string,
@@ -74,11 +92,20 @@ interface TurnSlot {
74
92
  done: Promise<void>;
75
93
  }
76
94
 
95
+ /**
96
+ * One entry buffered for serial-mode coalescing. Each successful `runSerial`
97
+ * call pushes one entry; the worker drains the entire buffer on the next
98
+ * turn boundary and merges them into a single dispatch.
99
+ */
100
+ interface BufferedSerialEntry {
101
+ route: GatewayRoute;
102
+ msg: GatewayInboundEnvelope["message"];
103
+ channel: ChannelAdapter;
104
+ }
105
+
77
106
  interface QueueState {
78
107
  /** The currently executing turn on this queue key, if any. */
79
108
  current: TurnSlot | null;
80
- /** Tail of the serial-mode queue — chained via promises; replaced each append. */
81
- tail: Promise<void>;
82
109
  /**
83
110
  * Generation counter bumped every time a cancel-previous turn arrives.
84
111
  * Any in-flight cancel-previous arrival captures the value at entry; if a
@@ -88,6 +115,15 @@ interface QueueState {
88
115
  * `current === null` after an abort and run concurrently.
89
116
  */
90
117
  cancelGen: number;
118
+ /**
119
+ * Serial-mode coalescing buffer. Messages pushed here while a turn is in
120
+ * flight are drained — and merged into a single user turn — on the next
121
+ * iteration of the worker loop. First message in an idle queue triggers a
122
+ * turn immediately; subsequent arrivals fold into the next batch.
123
+ */
124
+ serialBuffer: BufferedSerialEntry[];
125
+ /** True when the serial-drain worker is actively running (or about to). */
126
+ serialWorkerActive: boolean;
91
127
  }
92
128
 
93
129
  /**
@@ -149,12 +185,18 @@ export class Dispatcher {
149
185
  return;
150
186
  }
151
187
 
152
- // Compose the final user-turn text. The composer can enrich the raw
153
- // message with sender label, room header, NO_REPLY hint, etc. — anything
154
- // that should land in the runtime transcript. Failures fall back to the
155
- // raw trimmed text so a buggy composer cannot drop turns.
188
+ const managed = this.managedRoutes ? Array.from(this.managedRoutes.values()) : undefined;
189
+ const route = resolveRoute(msg, this.config, managed);
190
+ const mode = resolveQueueMode(route, msg.conversation.kind);
191
+ const queueKey = buildQueueKey(msg);
192
+
193
+ // Compose the final user-turn text only for cancel-previous mode, where
194
+ // the dispatcher consumes the pre-composed text directly. Serial mode
195
+ // re-runs the composer at drain time on the merged message (so it sees
196
+ // the full coalesced batch instead of any single arrival), so calling
197
+ // the composer here would just be redundant work.
156
198
  let text = rawText;
157
- if (this.composeUserTurn) {
199
+ if (mode === "cancel-previous" && this.composeUserTurn) {
158
200
  try {
159
201
  const composed = this.composeUserTurn(msg);
160
202
  if (typeof composed === "string" && composed.length > 0) {
@@ -168,11 +210,6 @@ export class Dispatcher {
168
210
  }
169
211
  }
170
212
 
171
- const managed = this.managedRoutes ? Array.from(this.managedRoutes.values()) : undefined;
172
- const route = resolveRoute(msg, this.config, managed);
173
- const mode = resolveQueueMode(route, msg.conversation.kind);
174
- const queueKey = buildQueueKey(msg);
175
-
176
213
  // Ack immediately: once the dispatcher has a route + queue key, ownership is decided.
177
214
  await this.safeAck(envelope);
178
215
 
@@ -236,8 +273,9 @@ export class Dispatcher {
236
273
  if (!q) {
237
274
  q = {
238
275
  current: null,
239
- tail: Promise.resolve(),
240
276
  cancelGen: 0,
277
+ serialBuffer: [],
278
+ serialWorkerActive: false,
241
279
  };
242
280
  this.queues.set(key, q);
243
281
  }
@@ -274,18 +312,164 @@ export class Dispatcher {
274
312
  await this.runTurn(queueKey, route, text, msg, channel);
275
313
  }
276
314
 
315
+ /**
316
+ * Serial mode with coalesce-on-drain semantics:
317
+ *
318
+ * 1. First arrival on an idle queue boots the worker, which dispatches a
319
+ * single-message turn immediately (no batching delay).
320
+ * 2. Arrivals during an in-flight turn append to `serialBuffer`; when the
321
+ * worker finishes the current turn it drains the entire buffer and
322
+ * merges all pending entries into ONE next turn (folded into a single
323
+ * `raw.batch` so the composer renders them as multi-block input).
324
+ * 3. Buffer caps: at most `MAX_BATCH_BUFFER_ENTRIES` entries are retained
325
+ * (drop oldest) and merged turns are further trimmed to fit
326
+ * `MAX_BATCH_BUFFER_CHARS` of total raw text.
327
+ *
328
+ * Note: the pre-composed `text` from `handle()` is intentionally discarded
329
+ * here — at drain time the worker re-invokes `composeUserTurn` on the
330
+ * merged message so the runtime sees a single coherent prompt covering all
331
+ * coalesced messages.
332
+ */
277
333
  private async runSerial(
278
334
  queueKey: string,
279
335
  route: GatewayRoute,
280
- text: string,
336
+ _text: string,
281
337
  msg: GatewayInboundEnvelope["message"],
282
338
  channel: ChannelAdapter,
283
339
  ): Promise<void> {
284
340
  const q = this.getQueue(queueKey);
285
- const prev = q.tail;
286
- const next = prev.then(() => this.runTurn(queueKey, route, text, msg, channel));
287
- q.tail = next.catch(() => undefined);
288
- return next;
341
+ q.serialBuffer.push({ route, msg, channel });
342
+ while (q.serialBuffer.length > MAX_BATCH_BUFFER_ENTRIES) {
343
+ const dropped = q.serialBuffer.shift()!;
344
+ this.log.warn("dispatcher: serial buffer overflow — dropped oldest entry", {
345
+ queueKey,
346
+ droppedMessageId: dropped.msg.id,
347
+ bufferCap: MAX_BATCH_BUFFER_ENTRIES,
348
+ });
349
+ }
350
+ if (q.serialWorkerActive) return;
351
+ q.serialWorkerActive = true;
352
+ try {
353
+ while (q.serialBuffer.length > 0) {
354
+ const drained = q.serialBuffer.splice(0, q.serialBuffer.length);
355
+ const merged = this.mergeSerialBuffer(drained, queueKey);
356
+ if (!merged) continue;
357
+ await this.runTurn(
358
+ queueKey,
359
+ merged.route,
360
+ merged.text,
361
+ merged.msg,
362
+ merged.channel,
363
+ );
364
+ }
365
+ } finally {
366
+ q.serialWorkerActive = false;
367
+ }
368
+ }
369
+
370
+ /**
371
+ * Merge buffered serial entries into a single dispatchable unit. With one
372
+ * entry the call is a near no-op (just recompose). With ≥2 entries this
373
+ * flattens any per-entry `raw.batch` (the BotCord channel already groups
374
+ * one inbox-poll's worth of same-room/topic messages into a `raw.batch`),
375
+ * applies the `MAX_BATCH_BUFFER_CHARS` cap by dropping oldest individual
376
+ * messages, and then synthesizes a merged inbound message anchored on the
377
+ * latest entry's metadata (mentioned = OR across all entries).
378
+ */
379
+ private mergeSerialBuffer(
380
+ entries: BufferedSerialEntry[],
381
+ queueKey: string,
382
+ ): {
383
+ route: GatewayRoute;
384
+ text: string;
385
+ msg: GatewayInboundEnvelope["message"];
386
+ channel: ChannelAdapter;
387
+ } | null {
388
+ if (entries.length === 0) return null;
389
+ if (entries.length === 1) {
390
+ const only = entries[0]!;
391
+ return {
392
+ route: only.route,
393
+ text: this.recomposeUserTurn(only.msg),
394
+ msg: only.msg,
395
+ channel: only.channel,
396
+ };
397
+ }
398
+
399
+ // Flatten: each entry's raw may already be a BatchedInboxRaw with
400
+ // `.batch`; otherwise it's a single InboxMessage we treat as a 1-element
401
+ // batch. Insertion order preserves chronology.
402
+ const items: Array<Record<string, unknown>> = [];
403
+ for (const e of entries) {
404
+ const raw = e.msg.raw as Record<string, unknown> | null | undefined;
405
+ const batch = raw && Array.isArray((raw as { batch?: unknown }).batch)
406
+ ? ((raw as { batch: Array<Record<string, unknown>> }).batch)
407
+ : null;
408
+ if (batch) {
409
+ for (const m of batch) items.push(m);
410
+ } else if (raw) {
411
+ items.push(raw);
412
+ }
413
+ }
414
+
415
+ // Char-cap: drop oldest until we fit. Reserve at least one item so we
416
+ // never produce an empty merged batch.
417
+ let totalChars = items.reduce(
418
+ (acc, m) => acc + (typeof m?.text === "string" ? (m.text as string).length : 0),
419
+ 0,
420
+ );
421
+ let droppedCount = 0;
422
+ while (totalChars > MAX_BATCH_BUFFER_CHARS && items.length > 1) {
423
+ const removed = items.shift()!;
424
+ totalChars -= typeof removed?.text === "string" ? (removed.text as string).length : 0;
425
+ droppedCount += 1;
426
+ }
427
+ if (droppedCount > 0) {
428
+ this.log.warn("dispatcher: merged batch exceeded char cap — dropped oldest", {
429
+ queueKey,
430
+ droppedCount,
431
+ remaining: items.length,
432
+ totalChars,
433
+ charCap: MAX_BATCH_BUFFER_CHARS,
434
+ });
435
+ }
436
+
437
+ const latest = entries[entries.length - 1]!;
438
+ const latestRaw = (latest.msg.raw as Record<string, unknown> | null | undefined) ?? {};
439
+ const mergedRaw = { ...latestRaw, batch: items };
440
+ const anyMentioned = entries.some((e) => e.msg.mentioned === true);
441
+ const mergedMsg: GatewayInboundEnvelope["message"] = {
442
+ ...latest.msg,
443
+ mentioned: anyMentioned,
444
+ raw: mergedRaw,
445
+ };
446
+ return {
447
+ route: latest.route,
448
+ text: this.recomposeUserTurn(mergedMsg),
449
+ msg: mergedMsg,
450
+ channel: latest.channel,
451
+ };
452
+ }
453
+
454
+ /**
455
+ * Re-run the user-turn composer at drain time. Mirrors the logic in
456
+ * `handle()` but operates on the (possibly merged) message. Falls back to
457
+ * raw trimmed text on composer failure so a buggy composer never drops a
458
+ * turn.
459
+ */
460
+ private recomposeUserTurn(msg: GatewayInboundEnvelope["message"]): string {
461
+ const rawText = typeof msg.text === "string" ? msg.text.trim() : "";
462
+ if (!this.composeUserTurn) return rawText;
463
+ try {
464
+ const composed = this.composeUserTurn(msg);
465
+ if (typeof composed === "string" && composed.length > 0) return composed;
466
+ } catch (err) {
467
+ this.log.warn("dispatcher: composeUserTurn (drain) threw — using raw text", {
468
+ messageId: msg.id,
469
+ error: err instanceof Error ? err.message : String(err),
470
+ });
471
+ }
472
+ return rawText;
289
473
  }
290
474
 
291
475
  private async runTurn(
@@ -413,16 +597,42 @@ export class Dispatcher {
413
597
  return;
414
598
  }
415
599
 
600
+ // Reply gating: only owner-chat rooms accept the runtime's plain text
601
+ // output as a delivered message. Every other room expects the agent to
602
+ // call the `botcord_send` tool (or `botcord send` CLI via Bash)
603
+ // explicitly; runtime text in those rooms is logged and dropped,
604
+ // including timeout / error notifications.
605
+ //
606
+ // Owner-chat is identified by either the `rm_oc_` room prefix OR
607
+ // `source_type === "dashboard_user_chat"` on the raw envelope — the
608
+ // same dual check used by `sender-classify.ts:classifyActivitySender`,
609
+ // so the dispatcher's reply gating stays in lock-step with the
610
+ // composer's owner-bypass.
611
+ //
612
+ // Side effect: `onOutbound` (loop-risk tracking) only fires when a
613
+ // reply actually leaves the dispatcher. In non-owner-chat rooms the
614
+ // expectation is that the agent's `botcord_send` tool calls do their
615
+ // own loop-risk accounting downstream.
616
+ const isOwnerChat = isOwnerChatRoom(msg);
617
+
416
618
  if (slot.timedOut) {
417
- await this.sendReply(channel, {
418
- channel: msg.channel,
419
- accountId: msg.accountId,
420
- conversationId: msg.conversation.id,
421
- threadId: msg.conversation.threadId ?? null,
422
- text: `⚠️ Runtime timeout after ${Math.round(this.turnTimeoutMs / 60000)} minute(s); aborted`,
423
- replyTo: msg.id,
424
- traceId: msg.trace?.id ?? null,
425
- });
619
+ if (isOwnerChat) {
620
+ await this.sendReply(channel, {
621
+ channel: msg.channel,
622
+ accountId: msg.accountId,
623
+ conversationId: msg.conversation.id,
624
+ threadId: msg.conversation.threadId ?? null,
625
+ text: `⚠️ Runtime timeout after ${Math.round(this.turnTimeoutMs / 60000)} minute(s); aborted`,
626
+ replyTo: msg.id,
627
+ traceId: msg.trace?.id ?? null,
628
+ });
629
+ } else {
630
+ this.log.warn("dispatcher: timeout in non-owner-chat room — error reply suppressed", {
631
+ queueKey,
632
+ conversationId: msg.conversation.id,
633
+ timeoutMs: this.turnTimeoutMs,
634
+ });
635
+ }
426
636
  return;
427
637
  }
428
638
 
@@ -432,16 +642,23 @@ export class Dispatcher {
432
642
  runtime: route.runtime,
433
643
  error: threw instanceof Error ? threw.message : String(threw),
434
644
  });
435
- const shortMsg = threw instanceof Error ? threw.message : String(threw);
436
- await this.sendReply(channel, {
437
- channel: msg.channel,
438
- accountId: msg.accountId,
439
- conversationId: msg.conversation.id,
440
- threadId: msg.conversation.threadId ?? null,
441
- text: `⚠️ Runtime error: ${truncate(shortMsg, 500)}`,
442
- replyTo: msg.id,
443
- traceId: msg.trace?.id ?? null,
444
- });
645
+ if (isOwnerChat) {
646
+ const shortMsg = threw instanceof Error ? threw.message : String(threw);
647
+ await this.sendReply(channel, {
648
+ channel: msg.channel,
649
+ accountId: msg.accountId,
650
+ conversationId: msg.conversation.id,
651
+ threadId: msg.conversation.threadId ?? null,
652
+ text: `⚠️ Runtime error: ${truncate(shortMsg, 500)}`,
653
+ replyTo: msg.id,
654
+ traceId: msg.trace?.id ?? null,
655
+ });
656
+ } else {
657
+ this.log.warn("dispatcher: runtime error in non-owner-chat room — error reply suppressed", {
658
+ queueKey,
659
+ conversationId: msg.conversation.id,
660
+ });
661
+ }
445
662
  return;
446
663
  }
447
664
 
@@ -502,6 +719,22 @@ export class Dispatcher {
502
719
  const replyText = (result.text || "").trim();
503
720
  if (!replyText) return;
504
721
 
722
+ if (!isOwnerChat) {
723
+ // Non-owner-chat rooms: result.text never goes out. The agent is
724
+ // expected to have used the `botcord_send` tool / `botcord send` CLI
725
+ // already; whatever it left in the runtime's final assistant text is
726
+ // discarded so it doesn't leak into the room.
727
+ this.log.debug(
728
+ "dispatcher: non-owner-chat — discarding result.text (agent must use botcord_send)",
729
+ {
730
+ queueKey,
731
+ conversationId: msg.conversation.id,
732
+ replyTextLen: replyText.length,
733
+ },
734
+ );
735
+ return;
736
+ }
737
+
505
738
  // One last abort check immediately before the send. Narrows the window
506
739
  // in which a cancel-previous arriving during session-store.set could
507
740
  // still slip a stale reply past us.
@@ -561,6 +794,28 @@ function buildQueueKey(msg: GatewayInboundEnvelope["message"]): string {
561
794
  return `${msg.channel}:${msg.accountId}:${msg.conversation.id}:${thread}`;
562
795
  }
563
796
 
797
+ /**
798
+ * Owner-chat predicate used by the dispatcher's reply gating. Matches the
799
+ * dual check in `sender-classify.ts:classifyActivitySender` so the
800
+ * dispatcher's gate stays consistent with the composer's owner-bypass:
801
+ *
802
+ * 1. `rm_oc_*` room id, OR
803
+ * 2. `source_type === "dashboard_user_chat"` on the raw envelope.
804
+ *
805
+ * The latter exists because the dashboard's user-chat surface can route
806
+ * messages through non-`rm_oc_` rooms in some flows; treating them as
807
+ * owner-trust here keeps the agent's plain reply text reachable.
808
+ */
809
+ function isOwnerChatRoom(msg: GatewayInboundEnvelope["message"]): boolean {
810
+ if (msg.conversation.id.startsWith(OWNER_CHAT_ROOM_PREFIX)) return true;
811
+ const raw = msg.raw;
812
+ if (raw && typeof raw === "object") {
813
+ const sourceType = (raw as { source_type?: unknown }).source_type;
814
+ if (sourceType === "dashboard_user_chat") return true;
815
+ }
816
+ return false;
817
+ }
818
+
564
819
  function resolveQueueMode(
565
820
  route: GatewayRoute,
566
821
  kind: "direct" | "group",
@@ -107,12 +107,18 @@ export class ClaudeCodeAdapter extends NdjsonStreamAdapter {
107
107
  args.push("--resume", opts.sessionId);
108
108
  }
109
109
  // Permission-mode policy:
110
- // - owner: acceptEdits (owner trusts their own agent).
111
- // - non-owner (trusted/public): default (let Claude Code prompt / reject edits per its own rules).
110
+ // - owner: bypassPermissions (owner fully trusts their own agent; daemon
111
+ // has no authorization-relay UI yet, so any other mode causes Bash /
112
+ // WebFetch / MCP tool calls to deadlock waiting for a prompt that
113
+ // never reaches the user — see issue #332 for the planned MCP-bridge
114
+ // relay that will let us tighten this back up).
115
+ // - non-owner (trusted/public): default (let Claude Code prompt / reject
116
+ // per its own rules — we must NOT auto-bypass for agents the operator
117
+ // doesn't own).
112
118
  // `extraArgs` still wins — operators who know what they're doing can override either.
113
119
  if (!opts.extraArgs?.some((a) => a.startsWith("--permission-mode"))) {
114
120
  if (opts.trustLevel === "owner") {
115
- args.push("--permission-mode", "acceptEdits");
121
+ args.push("--permission-mode", "bypassPermissions");
116
122
  } else {
117
123
  args.push("--permission-mode", "default");
118
124
  }
package/src/provision.ts 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";
@@ -297,9 +295,9 @@ async function materializeCredentials(
297
295
  ctx: ProvisionCtx,
298
296
  explicitCwd: string | undefined,
299
297
  ): Promise<StoredBotCordCredentials> {
300
- // Runtime is an agent property (docs/agent-runtime-property-plan.md §4.1).
301
- // Hub is authoritative; top-level `runtime` wins, `adapter` is a one-release
302
- // alias, and `credentials.runtime` is the per-agent cached copy.
298
+ // Runtime is an agent property. Hub is authoritative; top-level `runtime`
299
+ // wins, `adapter` is a one-release alias, and `credentials.runtime` is the
300
+ // per-agent cached copy.
303
301
  const runtime = pickRuntime(params);
304
302
  if (runtime) assertKnownRuntime(runtime);
305
303
 
@@ -662,8 +660,7 @@ function readAgentRuntimesFromCredentials(
662
660
  }
663
661
 
664
662
  /**
665
- * Per-agent entry returned by `list_agents`. Shape follows
666
- * `docs/daemon-control-plane-api-contract.md` §3.2 — `{id, name, online}`.
663
+ * Per-agent entry returned by `list_agents`. Wire shape: `{id, name, online}`.
667
664
  * `status` and `lastMessageAt` are extra daemon-only fields the dashboard
668
665
  * may ignore; kept so future contract revisions can promote them without
669
666
  * breaking the wire.
@@ -725,10 +722,10 @@ interface SetRouteResult {
725
722
  interface SetRouteParams {
726
723
  agentId?: string;
727
724
  /**
728
- * Contract shape (`docs/daemon-control-plane-api-contract.md` §3.2):
729
- * `{pattern, agentId}`. `pattern` is treated as a conversation-id prefix
730
- * (`rm_oc_*` etc.). When `route` is omitted, we synthesize a sensible
731
- * default route record using the daemon's existing default adapter+cwd.
725
+ * Contract shape `{pattern, agentId}`. `pattern` is treated as a
726
+ * conversation-id prefix (`rm_oc_*` etc.). When `route` is omitted, we
727
+ * synthesize a sensible default route record using the daemon's existing
728
+ * default adapter+cwd.
732
729
  */
733
730
  pattern?: string;
734
731
  /**
package/src/turn-text.ts CHANGED
@@ -34,6 +34,18 @@ const DIRECT_HINT =
34
34
  '[If the conversation has naturally concluded or no response is needed, ' +
35
35
  'reply with exactly "NO_REPLY" and nothing else.]';
36
36
 
37
+ /**
38
+ * Reminder appended to every wrapped (non-owner-chat) inbound message. The
39
+ * dispatcher discards `result.text` for any room that is not `rm_oc_*`, so
40
+ * the agent must call the `botcord_send` tool (or the `botcord send` CLI
41
+ * via Bash) to actually deliver a reply. Plain assistant text in those
42
+ * rooms is logged and dropped.
43
+ */
44
+ const NON_OWNER_REPLY_HINT =
45
+ "[This room is NOT owner-chat. Plain text output WILL NOT be sent. " +
46
+ "To reply, call the `botcord_send` tool, or run " +
47
+ '`botcord send --room <room_id> --text "..."` via Bash.]';
48
+
37
49
  /**
38
50
  * Read the BotCord envelope type from a raw inbound message. Returns
39
51
  * `undefined` when the message didn't come from the BotCord channel or the
@@ -169,6 +181,8 @@ export function composeBotCordUserTurn(msg: GatewayInboundMessage): string {
169
181
  `</${tag}>`,
170
182
  "",
171
183
  hint,
184
+ "",
185
+ NON_OWNER_REPLY_HINT,
172
186
  ];
173
187
  if (contactRequestHint) {
174
188
  lines.push("", contactRequestHint);
@@ -223,7 +237,14 @@ function composeBatchedTurn(
223
237
  }
224
238
 
225
239
  const hint = isGroup ? GROUP_HINT : DIRECT_HINT;
226
- const lines: string[] = [header.join(" | "), blocks.join("\n"), "", hint];
240
+ const lines: string[] = [
241
+ header.join(" | "),
242
+ blocks.join("\n"),
243
+ "",
244
+ hint,
245
+ "",
246
+ NON_OWNER_REPLY_HINT,
247
+ ];
227
248
 
228
249
  if (contactRequestSenders.length > 0) {
229
250
  // Dedup + list — multiple distinct senders show as "A, B".
package/src/user-auth.ts 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 {
12
10
  chmodSync,
@@ -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).