@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.
@@ -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
  */
@@ -102,3 +102,26 @@ export interface BatchedInboxRaw extends InboxMessage {
102
102
  */
103
103
  export declare function createBotCordChannel(options: BotCordChannelOptions): ChannelAdapter;
104
104
  export { normalizeInbox as __normalizeInboxForTests };
105
+ export { normalizeBlockForHub as __normalizeBlockForHubForTests };
106
+ /**
107
+ * Reshape a runtime StreamBlock `{ raw, kind, seq }` into the
108
+ * `{ kind, payload, seq }` form the owner-chat frontend renders.
109
+ *
110
+ * Daemon-internal kinds are Claude Code / Codex specific; the dashboard's
111
+ * StreamBlocksView expects a smaller vocabulary (`assistant`, `tool_call`,
112
+ * `tool_result`, `reasoning`) with structured `payload` fields. Without this
113
+ * remap the UI falls back to printing the bare kind string per step, which
114
+ * is what users see as "system / assistant_text / other / other".
115
+ *
116
+ * Extraction is best-effort — unknown shapes pass through as `other` with
117
+ * an empty payload rather than throwing.
118
+ */
119
+ declare function normalizeBlockForHub(block: {
120
+ raw?: unknown;
121
+ kind?: string;
122
+ seq?: number;
123
+ } | undefined, seq: number): {
124
+ kind: string;
125
+ seq: number;
126
+ payload: Record<string, unknown>;
127
+ };
@@ -555,6 +555,7 @@ export function createBotCordChannel(options) {
555
555
  try {
556
556
  const token = await client.ensureToken();
557
557
  const block = ctx.block;
558
+ const seq = typeof block?.seq === "number" ? block.seq : 0;
558
559
  const resp = await fetch(`${hubUrl}/hub/stream-block`, {
559
560
  method: "POST",
560
561
  headers: {
@@ -563,8 +564,8 @@ export function createBotCordChannel(options) {
563
564
  },
564
565
  body: JSON.stringify({
565
566
  trace_id: ctx.traceId,
566
- seq: typeof block?.seq === "number" ? block.seq : 0,
567
- block: ctx.block,
567
+ seq,
568
+ block: normalizeBlockForHub(block, seq),
568
569
  }),
569
570
  signal: AbortSignal.timeout(10_000),
570
571
  });
@@ -586,5 +587,94 @@ export function createBotCordChannel(options) {
586
587
  };
587
588
  return adapter;
588
589
  }
589
- // Re-export the normalizer for tests that want to exercise it directly.
590
+ // Re-export the normalizers for tests that want to exercise them directly.
590
591
  export { normalizeInbox as __normalizeInboxForTests };
592
+ export { normalizeBlockForHub as __normalizeBlockForHubForTests };
593
+ /**
594
+ * Reshape a runtime StreamBlock `{ raw, kind, seq }` into the
595
+ * `{ kind, payload, seq }` form the owner-chat frontend renders.
596
+ *
597
+ * Daemon-internal kinds are Claude Code / Codex specific; the dashboard's
598
+ * StreamBlocksView expects a smaller vocabulary (`assistant`, `tool_call`,
599
+ * `tool_result`, `reasoning`) with structured `payload` fields. Without this
600
+ * remap the UI falls back to printing the bare kind string per step, which
601
+ * is what users see as "system / assistant_text / other / other".
602
+ *
603
+ * Extraction is best-effort — unknown shapes pass through as `other` with
604
+ * an empty payload rather than throwing.
605
+ */
606
+ function normalizeBlockForHub(block, seq) {
607
+ const raw = (block?.raw ?? {});
608
+ const kind = block?.kind ?? "other";
609
+ const payload = {};
610
+ if (kind === "assistant_text") {
611
+ // Claude Code: {type:"assistant", message:{content:[{type:"text",text}]}}
612
+ // Codex: {type:"item.completed", item:{type:"agent_message", text}}
613
+ let text = "";
614
+ const contents = Array.isArray(raw?.message?.content) ? raw.message.content : [];
615
+ for (const c of contents) {
616
+ if (c?.type === "text" && typeof c.text === "string")
617
+ text += c.text;
618
+ }
619
+ if (!text && typeof raw?.item?.text === "string")
620
+ text = raw.item.text;
621
+ return { kind: "assistant", seq, payload: { text } };
622
+ }
623
+ if (kind === "tool_use") {
624
+ // Claude Code: assistant message w/ content[].type === "tool_use" → {id,name,input}
625
+ // Codex: item.started / item.completed for command_execution, file_change, mcp_tool_call, web_search
626
+ const contents = Array.isArray(raw?.message?.content) ? raw.message.content : [];
627
+ const tu = contents.find((c) => c?.type === "tool_use");
628
+ if (tu) {
629
+ payload.name = typeof tu.name === "string" ? tu.name : "tool";
630
+ if (tu.input && typeof tu.input === "object")
631
+ payload.params = tu.input;
632
+ if (typeof tu.id === "string")
633
+ payload.id = tu.id;
634
+ }
635
+ else if (raw?.item && typeof raw.item === "object") {
636
+ payload.name = typeof raw.item.type === "string" ? raw.item.type : "tool";
637
+ payload.params = raw.item;
638
+ }
639
+ return { kind: "tool_call", seq, payload };
640
+ }
641
+ if (kind === "tool_result") {
642
+ // Claude Code: {type:"user", message:{content:[{type:"tool_result",tool_use_id,content}]}}
643
+ const contents = Array.isArray(raw?.message?.content) ? raw.message.content : [];
644
+ const tr = contents.find((c) => c?.type === "tool_result");
645
+ if (tr) {
646
+ let resultStr = "";
647
+ if (typeof tr.content === "string") {
648
+ resultStr = tr.content;
649
+ }
650
+ else if (Array.isArray(tr.content)) {
651
+ resultStr = tr.content
652
+ .map((c) => (typeof c?.text === "string" ? c.text : JSON.stringify(c)))
653
+ .join("\n");
654
+ }
655
+ payload.result = resultStr;
656
+ if (typeof tr.tool_use_id === "string")
657
+ payload.tool_use_id = tr.tool_use_id;
658
+ }
659
+ return { kind: "tool_result", seq, payload };
660
+ }
661
+ if (kind === "system") {
662
+ if (typeof raw?.subtype === "string")
663
+ payload.subtype = raw.subtype;
664
+ if (typeof raw?.session_id === "string")
665
+ payload.session_id = raw.session_id;
666
+ if (typeof raw?.model === "string")
667
+ payload.model = raw.model;
668
+ return { kind: "system", seq, payload };
669
+ }
670
+ // "other" — e.g. Claude Code `type:"result"` end-of-turn summary.
671
+ if (raw?.type === "result") {
672
+ if (typeof raw.result === "string")
673
+ payload.text = raw.result;
674
+ if (typeof raw.subtype === "string")
675
+ payload.subtype = raw.subtype;
676
+ if (typeof raw.total_cost_usd === "number")
677
+ payload.total_cost_usd = raw.total_cost_usd;
678
+ }
679
+ return { kind: "other", seq, payload };
680
+ }
@@ -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;
@@ -88,12 +88,18 @@ export class ClaudeCodeAdapter extends NdjsonStreamAdapter {
88
88
  args.push("--resume", opts.sessionId);
89
89
  }
90
90
  // Permission-mode policy:
91
- // - owner: acceptEdits (owner trusts their own agent).
92
- // - non-owner (trusted/public): default (let Claude Code prompt / reject edits per its own rules).
91
+ // - owner: bypassPermissions (owner fully trusts their own agent; daemon
92
+ // has no authorization-relay UI yet, so any other mode causes Bash /
93
+ // WebFetch / MCP tool calls to deadlock waiting for a prompt that
94
+ // never reaches the user — see issue #332 for the planned MCP-bridge
95
+ // relay that will let us tighten this back up).
96
+ // - non-owner (trusted/public): default (let Claude Code prompt / reject
97
+ // per its own rules — we must NOT auto-bypass for agents the operator
98
+ // doesn't own).
93
99
  // `extraArgs` still wins — operators who know what they're doing can override either.
94
100
  if (!opts.extraArgs?.some((a) => a.startsWith("--permission-mode"))) {
95
101
  if (opts.trustLevel === "owner") {
96
- args.push("--permission-mode", "acceptEdits");
102
+ args.push("--permission-mode", "bypassPermissions");
97
103
  }
98
104
  else {
99
105
  args.push("--permission-mode", "default");
@@ -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.