@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
|
@@ -17,9 +17,9 @@ export interface DiscoveredAgentCredential {
|
|
|
17
17
|
hubUrl: string;
|
|
18
18
|
displayName?: string;
|
|
19
19
|
/**
|
|
20
|
-
* Runtime cached in the credentials file
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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`. */
|
package/dist/agent-discovery.js
CHANGED
|
@@ -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
|
|
137
|
+
// fallback.
|
|
138
138
|
const agents = explicit.map((agentId) => {
|
|
139
139
|
const credentialsFile = defaultCredentialsFile(agentId);
|
|
140
140
|
const entry = {
|
package/dist/agent-workspace.js
CHANGED
|
@@ -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}`.
|
|
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)
|
package/dist/control-channel.js
CHANGED
|
@@ -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}`.
|
|
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
|
|
13
|
-
* `
|
|
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
|
|
567
|
-
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
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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:
|
|
92
|
-
//
|
|
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", "
|
|
102
|
+
args.push("--permission-mode", "bypassPermissions");
|
|
97
103
|
}
|
|
98
104
|
else {
|
|
99
105
|
args.push("--permission-mode", "default");
|
package/dist/provision.d.ts
CHANGED
|
@@ -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`.
|
|
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.
|