@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.
- package/dist/agent-discovery.d.ts +3 -3
- package/dist/agent-discovery.js +1 -1
- package/dist/agent-workspace.js +0 -2
- package/dist/control-channel.d.ts +1 -4
- package/dist/control-channel.js +1 -4
- package/dist/daemon-config-map.d.ts +2 -3
- package/dist/gateway/channels/botcord.d.ts +23 -0
- package/dist/gateway/channels/botcord.js +93 -3
- package/dist/gateway/dispatcher.d.ts +35 -0
- package/dist/gateway/dispatcher.js +253 -34
- package/dist/gateway/runtimes/claude-code.js +9 -3
- package/dist/provision.d.ts +1 -2
- package/dist/provision.js +3 -5
- package/dist/turn-text.js +20 -1
- package/dist/user-auth.js +0 -2
- package/dist/working-memory.js +1 -1
- package/package.json +1 -1
- package/src/agent-discovery.ts +4 -4
- package/src/agent-workspace.ts +0 -2
- package/src/control-channel.ts +1 -4
- package/src/daemon-config-map.ts +2 -3
- package/src/gateway/__tests__/botcord-channel.test.ts +10 -3
- package/src/gateway/__tests__/claude-code-adapter.test.ts +2 -2
- package/src/gateway/__tests__/dispatcher.test.ts +375 -2
- package/src/gateway/channels/botcord.ts +89 -3
- package/src/gateway/dispatcher.ts +292 -37
- package/src/gateway/runtimes/claude-code.ts +9 -3
- package/src/provision.ts +8 -11
- package/src/turn-text.ts +22 -1
- package/src/user-auth.ts +0 -2
- package/src/working-memory.ts +1 -1
|
@@ -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
|
|
676
|
-
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
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
336
|
+
_text: string,
|
|
281
337
|
msg: GatewayInboundEnvelope["message"],
|
|
282
338
|
channel: ChannelAdapter,
|
|
283
339
|
): Promise<void> {
|
|
284
340
|
const q = this.getQueue(queueKey);
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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:
|
|
111
|
-
//
|
|
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", "
|
|
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
|
|
301
|
-
//
|
|
302
|
-
//
|
|
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`.
|
|
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
|
|
729
|
-
* `
|
|
730
|
-
*
|
|
731
|
-
* default
|
|
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[] = [
|
|
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,
|
package/src/working-memory.ts
CHANGED
|
@@ -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
|
|
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).
|