@botcord/daemon 0.2.3 → 0.2.4
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/gateway/channels/botcord.d.ts +23 -0
- package/dist/gateway/channels/botcord.js +93 -3
- package/dist/gateway/runtimes/claude-code.js +9 -3
- package/package.json +1 -1
- package/src/gateway/__tests__/botcord-channel.test.ts +10 -3
- package/src/gateway/__tests__/claude-code-adapter.test.ts +2 -2
- package/src/gateway/channels/botcord.ts +89 -3
- package/src/gateway/runtimes/claude-code.ts +9 -3
|
@@ -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
|
+
}
|
|
@@ -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/package.json
CHANGED
|
@@ -484,7 +484,11 @@ describe("createBotCordChannel — streamBlock()", () => {
|
|
|
484
484
|
traceId: "m_trace",
|
|
485
485
|
accountId: "ag_self",
|
|
486
486
|
conversationId: "rm_oc_1",
|
|
487
|
-
block: {
|
|
487
|
+
block: {
|
|
488
|
+
kind: "assistant_text",
|
|
489
|
+
seq: 3,
|
|
490
|
+
raw: { type: "assistant", message: { content: [{ type: "text", text: "partial" }] } },
|
|
491
|
+
},
|
|
488
492
|
log: silentLog,
|
|
489
493
|
});
|
|
490
494
|
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
@@ -493,10 +497,13 @@ describe("createBotCordChannel — streamBlock()", () => {
|
|
|
493
497
|
expect(init.method).toBe("POST");
|
|
494
498
|
const body = JSON.parse(init.body as string);
|
|
495
499
|
expect(body.trace_id).toBe("m_trace");
|
|
500
|
+
expect(body.seq).toBe(3);
|
|
501
|
+
// The channel remaps daemon-internal kinds into the shape the dashboard
|
|
502
|
+
// renders: `{ kind, payload, seq }` with `assistant_text` → `assistant`.
|
|
496
503
|
expect(body.block).toEqual({
|
|
497
|
-
kind: "
|
|
504
|
+
kind: "assistant",
|
|
498
505
|
seq: 3,
|
|
499
|
-
|
|
506
|
+
payload: { text: "partial" },
|
|
500
507
|
});
|
|
501
508
|
expect((init.headers as Record<string, string>).Authorization).toBe("Bearer test-token");
|
|
502
509
|
} finally {
|
|
@@ -211,7 +211,7 @@ process.stdout.write(JSON.stringify({type:"result", subtype:"success", session_i
|
|
|
211
211
|
`,
|
|
212
212
|
);
|
|
213
213
|
|
|
214
|
-
it("owner → --permission-mode
|
|
214
|
+
it("owner → --permission-mode bypassPermissions", async () => {
|
|
215
215
|
const adapter = new ClaudeCodeAdapter({ binary: echoScript() });
|
|
216
216
|
const ctrl = new AbortController();
|
|
217
217
|
const res = await adapter.run({
|
|
@@ -225,7 +225,7 @@ process.stdout.write(JSON.stringify({type:"result", subtype:"success", session_i
|
|
|
225
225
|
const argv = JSON.parse(res.text) as string[];
|
|
226
226
|
const modeIdx = argv.indexOf("--permission-mode");
|
|
227
227
|
expect(modeIdx).toBeGreaterThanOrEqual(0);
|
|
228
|
-
expect(argv[modeIdx + 1]).toBe("
|
|
228
|
+
expect(argv[modeIdx + 1]).toBe("bypassPermissions");
|
|
229
229
|
});
|
|
230
230
|
|
|
231
231
|
it("public → --permission-mode default", async () => {
|
|
@@ -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
|
+
}
|
|
@@ -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
|
}
|