@botcord/daemon 0.2.12 → 0.2.14
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.js +39 -0
- package/dist/gateway/dispatcher.d.ts +6 -0
- package/dist/gateway/dispatcher.js +207 -9
- package/dist/gateway/runtimes/acp-stream.d.ts +7 -1
- package/dist/gateway/runtimes/acp-stream.js +19 -0
- package/dist/gateway/runtimes/claude-code.js +34 -0
- package/dist/gateway/runtimes/codex.js +50 -0
- package/dist/gateway/runtimes/hermes-agent.d.ts +8 -3
- package/dist/gateway/runtimes/hermes-agent.js +36 -6
- package/dist/gateway/runtimes/ndjson-stream.d.ts +8 -1
- package/dist/gateway/runtimes/ndjson-stream.js +8 -0
- package/dist/gateway/types.d.ts +54 -2
- package/dist/index.js +72 -5
- package/dist/provision.js +63 -1
- package/package.json +1 -1
- package/src/__tests__/provision.test.ts +68 -1
- package/src/gateway/__tests__/botcord-channel.test.ts +97 -0
- package/src/gateway/__tests__/claude-code-adapter.test.ts +35 -0
- package/src/gateway/__tests__/codex-adapter.test.ts +44 -0
- package/src/gateway/__tests__/dispatcher.test.ts +552 -1
- package/src/gateway/__tests__/hermes-agent-adapter.test.ts +39 -0
- package/src/gateway/channels/botcord.ts +38 -0
- package/src/gateway/dispatcher.ts +217 -15
- package/src/gateway/runtimes/acp-stream.ts +24 -0
- package/src/gateway/runtimes/claude-code.ts +41 -1
- package/src/gateway/runtimes/codex.ts +58 -0
- package/src/gateway/runtimes/hermes-agent.ts +45 -5
- package/src/gateway/runtimes/ndjson-stream.ts +15 -0
- package/src/gateway/types.ts +55 -2
- package/src/index.ts +88 -5
- package/src/provision.ts +62 -1
|
@@ -581,6 +581,32 @@ export function createBotCordChannel(options) {
|
|
|
581
581
|
ctx.log.warn("botcord stream-block failed", { err: String(err) });
|
|
582
582
|
}
|
|
583
583
|
},
|
|
584
|
+
async typing(ctx) {
|
|
585
|
+
const client = ensureClient();
|
|
586
|
+
const hubUrl = options.hubBaseUrl ?? client.getHubUrl();
|
|
587
|
+
try {
|
|
588
|
+
const token = await client.ensureToken();
|
|
589
|
+
const resp = await fetch(`${hubUrl}/hub/typing`, {
|
|
590
|
+
method: "POST",
|
|
591
|
+
headers: {
|
|
592
|
+
"Content-Type": "application/json",
|
|
593
|
+
Authorization: `Bearer ${token}`,
|
|
594
|
+
},
|
|
595
|
+
body: JSON.stringify({ room_id: ctx.conversationId }),
|
|
596
|
+
signal: AbortSignal.timeout(10_000),
|
|
597
|
+
});
|
|
598
|
+
if (!resp.ok && resp.status !== 204) {
|
|
599
|
+
const body = await resp.text().catch(() => "");
|
|
600
|
+
ctx.log.warn("botcord typing non-ok", {
|
|
601
|
+
status: resp.status,
|
|
602
|
+
body: body.slice(0, 200),
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
catch (err) {
|
|
607
|
+
ctx.log.warn("botcord typing failed", { err: String(err) });
|
|
608
|
+
}
|
|
609
|
+
},
|
|
584
610
|
status() {
|
|
585
611
|
return { ...statusSnapshot };
|
|
586
612
|
},
|
|
@@ -667,6 +693,19 @@ function normalizeBlockForHub(block, seq) {
|
|
|
667
693
|
payload.model = raw.model;
|
|
668
694
|
return { kind: "system", seq, payload };
|
|
669
695
|
}
|
|
696
|
+
if (kind === "thinking") {
|
|
697
|
+
// Daemon-synthesized lifecycle marker. `raw` carries `{ phase, label?, source? }`
|
|
698
|
+
// — see Dispatcher's status forwarding. The frontend uses `phase` to decide
|
|
699
|
+
// whether to enter/leave the compact "Thinking..." UI; `label` is a free-form
|
|
700
|
+
// human hint (e.g. "Searching web"). Treat as untrusted text — never inject.
|
|
701
|
+
if (typeof raw?.phase === "string")
|
|
702
|
+
payload.phase = raw.phase;
|
|
703
|
+
if (typeof raw?.label === "string")
|
|
704
|
+
payload.label = raw.label;
|
|
705
|
+
if (typeof raw?.source === "string")
|
|
706
|
+
payload.source = raw.source;
|
|
707
|
+
return { kind: "thinking", seq, payload };
|
|
708
|
+
}
|
|
670
709
|
// "other" — e.g. Claude Code `type:"result"` end-of-turn summary.
|
|
671
710
|
if (raw?.type === "result") {
|
|
672
711
|
if (typeof raw.result === "string")
|
|
@@ -94,6 +94,12 @@ export declare class Dispatcher {
|
|
|
94
94
|
private readonly resolveHubUrl?;
|
|
95
95
|
private readonly transcript;
|
|
96
96
|
private readonly queues;
|
|
97
|
+
/**
|
|
98
|
+
* Last `/hub/typing` ping timestamp per (accountId, conversationId).
|
|
99
|
+
* Used to debounce cancel-previous bursts so we don't trip Hub's 20/min
|
|
100
|
+
* rate limit. True LRU (delete + set on access) capped at TYPING_RECENCY_CAP.
|
|
101
|
+
*/
|
|
102
|
+
private readonly recentTypingPings;
|
|
97
103
|
constructor(opts: DispatcherOptions);
|
|
98
104
|
/** Consume one inbound envelope, ack it once ownership is decided, then run its turn. */
|
|
99
105
|
handle(envelope: GatewayInboundEnvelope): Promise<void>;
|
|
@@ -18,6 +18,14 @@ const MAX_BATCH_BUFFER_ENTRIES = 40;
|
|
|
18
18
|
* runtime prompt stays bounded even if the channel-side batch was huge.
|
|
19
19
|
*/
|
|
20
20
|
const MAX_BATCH_BUFFER_CHARS = 16000;
|
|
21
|
+
/**
|
|
22
|
+
* Per-(accountId, conversationId) cooldown between successive `/hub/typing`
|
|
23
|
+
* pings. Hub rate-limits to 20 typing/min per agent (backend hub.py:1675);
|
|
24
|
+
* cancel-previous bursts on a fast user can otherwise trip 429 silently.
|
|
25
|
+
*/
|
|
26
|
+
const TYPING_DEBOUNCE_MS = 2000;
|
|
27
|
+
/** LRU cap on the typing-recency map so long-running daemons don't grow unbounded. */
|
|
28
|
+
const TYPING_RECENCY_CAP = 1024;
|
|
21
29
|
/**
|
|
22
30
|
* Reason carried on `AbortController.abort()` when a cancel-previous wave
|
|
23
31
|
* is taking over the slot. Distinguishing this from a timeout abort lets
|
|
@@ -63,6 +71,12 @@ export class Dispatcher {
|
|
|
63
71
|
resolveHubUrl;
|
|
64
72
|
transcript;
|
|
65
73
|
queues = new Map();
|
|
74
|
+
/**
|
|
75
|
+
* Last `/hub/typing` ping timestamp per (accountId, conversationId).
|
|
76
|
+
* Used to debounce cancel-previous bursts so we don't trip Hub's 20/min
|
|
77
|
+
* rate limit. True LRU (delete + set on access) capped at TYPING_RECENCY_CAP.
|
|
78
|
+
*/
|
|
79
|
+
recentTypingPings = new Map();
|
|
66
80
|
constructor(opts) {
|
|
67
81
|
this.config = opts.config;
|
|
68
82
|
this.channels = opts.channels;
|
|
@@ -551,26 +565,204 @@ export class Dispatcher {
|
|
|
551
565
|
}
|
|
552
566
|
slot.blocks.push(summary);
|
|
553
567
|
};
|
|
554
|
-
|
|
568
|
+
// Owner-chat lifecycle state for typing/thinking. The dispatcher is the
|
|
569
|
+
// only component that sees turn boundaries + channel capabilities + trace
|
|
570
|
+
// ids together, so it owns the收束: once `typing.started` fires we never
|
|
571
|
+
// re-fire it within this turn (frontend clears via stream/message
|
|
572
|
+
// arrival), and `thinking` is auto-synthesized on the first non-assistant
|
|
573
|
+
// block so adapters that emit nothing-but-blocks still drive the
|
|
574
|
+
// "Thinking..." UI.
|
|
575
|
+
let typingFired = false;
|
|
576
|
+
let thinkingActive = false;
|
|
577
|
+
/**
|
|
578
|
+
* Sticky: once we've forwarded any assistant_text to the wire, we stop
|
|
579
|
+
* auto-synthesizing thinking on plain `system`/`other` blocks. This
|
|
580
|
+
* prevents the post-prose flicker caused by Codex's `turn.completed` /
|
|
581
|
+
* Claude Code's `result` (both arrive as system/other AFTER the prose).
|
|
582
|
+
* `tool_use` is the explicit exception — agents that legitimately go
|
|
583
|
+
* back to work after a partial answer should still drive "Thinking…".
|
|
584
|
+
*/
|
|
585
|
+
let sawAssistantText = false;
|
|
586
|
+
let blocksSent = 0;
|
|
587
|
+
const forwardBlockToChannel = canStream
|
|
555
588
|
? (block) => {
|
|
556
|
-
|
|
557
|
-
//
|
|
558
|
-
|
|
559
|
-
|
|
589
|
+
// Re-sequence at the wire boundary so synthesized thinking blocks
|
|
590
|
+
// interleave cleanly with adapter-emitted blocks; adapters keep
|
|
591
|
+
// their own per-turn seq for tracing/logging only.
|
|
592
|
+
blocksSent += 1;
|
|
593
|
+
const ctx = {
|
|
560
594
|
traceId: traceId,
|
|
561
595
|
accountId: msg.accountId,
|
|
562
596
|
conversationId: msg.conversation.id,
|
|
563
|
-
block,
|
|
597
|
+
block: { ...block, seq: blocksSent },
|
|
564
598
|
log: this.log,
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
|
|
599
|
+
};
|
|
600
|
+
// Coerce a synchronous throw from a non-async adapter into the same
|
|
601
|
+
// warn path as an async rejection so a buggy channel never tears
|
|
602
|
+
// down the turn (the adapter contract is fire-and-forget).
|
|
603
|
+
try {
|
|
604
|
+
const ret = channel.streamBlock(ctx);
|
|
605
|
+
if (ret && typeof ret.catch === "function") {
|
|
606
|
+
ret.catch((err) => {
|
|
607
|
+
this.log.warn("dispatcher: streamBlock failed", {
|
|
608
|
+
traceId,
|
|
609
|
+
error: err instanceof Error ? err.message : String(err),
|
|
610
|
+
});
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
catch (err) {
|
|
615
|
+
this.log.warn("dispatcher: streamBlock threw", {
|
|
568
616
|
traceId,
|
|
569
617
|
error: err instanceof Error ? err.message : String(err),
|
|
570
618
|
});
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
: undefined;
|
|
622
|
+
const sendThinkingMarker = (phase, label, source) => {
|
|
623
|
+
if (!forwardBlockToChannel)
|
|
624
|
+
return;
|
|
625
|
+
const raw = { phase, source };
|
|
626
|
+
if (label)
|
|
627
|
+
raw.label = label;
|
|
628
|
+
const synth = { raw, kind: "thinking", seq: 0 };
|
|
629
|
+
// Intentionally NOT `recordBlock(synth)` — the transcript stays
|
|
630
|
+
// adapter-truth so downstream log consumers see only what the runtime
|
|
631
|
+
// actually emitted, not daemon-synthesized lifecycle frames.
|
|
632
|
+
forwardBlockToChannel(synth);
|
|
633
|
+
};
|
|
634
|
+
const fireTypingIfNeeded = () => {
|
|
635
|
+
if (!canStream || typingFired || typeof channel.typing !== "function")
|
|
636
|
+
return;
|
|
637
|
+
typingFired = true;
|
|
638
|
+
const key = `${msg.accountId}:${msg.conversation.id}`;
|
|
639
|
+
const now = Date.now();
|
|
640
|
+
const last = this.recentTypingPings.get(key);
|
|
641
|
+
if (last !== undefined && now - last < TYPING_DEBOUNCE_MS) {
|
|
642
|
+
// Within the debounce window — Hub's 2s dedup absorbs this. The
|
|
643
|
+
// window thins out cancel-previous bursts; it does NOT fully
|
|
644
|
+
// prevent 429s when many active rooms ping concurrently, so the
|
|
645
|
+
// try/catch around `channel.typing()` is what actually keeps the
|
|
646
|
+
// turn alive on rate-limit (backend hub.py:1675).
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
// True LRU: delete-then-set bumps the entry to the tail of the Map
|
|
650
|
+
// insertion order, so chronically active conversations never get
|
|
651
|
+
// evicted by an unrelated newcomer at the cap.
|
|
652
|
+
this.recentTypingPings.delete(key);
|
|
653
|
+
this.recentTypingPings.set(key, now);
|
|
654
|
+
if (this.recentTypingPings.size > TYPING_RECENCY_CAP) {
|
|
655
|
+
const oldest = this.recentTypingPings.keys().next().value;
|
|
656
|
+
if (oldest !== undefined)
|
|
657
|
+
this.recentTypingPings.delete(oldest);
|
|
658
|
+
}
|
|
659
|
+
const ctx = {
|
|
660
|
+
traceId: traceId,
|
|
661
|
+
accountId: msg.accountId,
|
|
662
|
+
conversationId: msg.conversation.id,
|
|
663
|
+
log: this.log,
|
|
664
|
+
};
|
|
665
|
+
try {
|
|
666
|
+
const ret = channel.typing(ctx);
|
|
667
|
+
if (ret && typeof ret.catch === "function") {
|
|
668
|
+
ret.catch((err) => {
|
|
669
|
+
this.log.warn("dispatcher: channel.typing failed", {
|
|
670
|
+
traceId,
|
|
671
|
+
error: err instanceof Error ? err.message : String(err),
|
|
672
|
+
});
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
catch (err) {
|
|
677
|
+
this.log.warn("dispatcher: channel.typing threw", {
|
|
678
|
+
traceId,
|
|
679
|
+
error: err instanceof Error ? err.message : String(err),
|
|
571
680
|
});
|
|
572
681
|
}
|
|
682
|
+
};
|
|
683
|
+
const onStatus = canStream
|
|
684
|
+
? (event) => {
|
|
685
|
+
// Drop runtime callbacks after this turn's controller aborts —
|
|
686
|
+
// NDJSON/ACP adapters keep parsing stdout until the child exits
|
|
687
|
+
// (up to KILL_GRACE_MS after SIGTERM), so without this guard a
|
|
688
|
+
// superseded turn leaks frames to the new turn's UI.
|
|
689
|
+
if (controller.signal.aborted)
|
|
690
|
+
return;
|
|
691
|
+
if (event.kind === "typing") {
|
|
692
|
+
// `/hub/typing` has no stopped semantic — frontend self-clears on
|
|
693
|
+
// stream/message arrival. typing.stopped is observed for daemon
|
|
694
|
+
// bookkeeping only (currently a no-op; kept for telemetry).
|
|
695
|
+
if (event.phase === "started")
|
|
696
|
+
fireTypingIfNeeded();
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
if (event.phase === "stopped") {
|
|
700
|
+
// Forward to wire ONLY if we previously announced thinking — that
|
|
701
|
+
// way `finalizeThinkingIfActive` doesn't double-emit, and adapters
|
|
702
|
+
// that signal terminal closure earlier than child exit (e.g.
|
|
703
|
+
// acp-stream's prompt-done) reach the frontend without waiting.
|
|
704
|
+
if (thinkingActive) {
|
|
705
|
+
sendThinkingMarker("stopped", event.label, "runtime");
|
|
706
|
+
}
|
|
707
|
+
thinkingActive = false;
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
// Runtime-emitted thinking.started/.updated is trusted unconditionally:
|
|
711
|
+
// we deliberately do NOT apply the `sawAssistantText` sticky guard
|
|
712
|
+
// here, only to dispatcher-synthesized starts. Adapters opting into
|
|
713
|
+
// explicit status events accept the responsibility of driving a
|
|
714
|
+
// sensible lifecycle (don't fire .started after the final answer).
|
|
715
|
+
thinkingActive = true;
|
|
716
|
+
sendThinkingMarker(event.phase, event.label, "runtime");
|
|
717
|
+
}
|
|
573
718
|
: undefined;
|
|
719
|
+
const onBlock = canStream
|
|
720
|
+
? (block) => {
|
|
721
|
+
// Always record adapter-emitted blocks for transcript fidelity, even
|
|
722
|
+
// after abort — the transcript reflects what the runtime emitted,
|
|
723
|
+
// not what the dispatcher chose to forward.
|
|
724
|
+
recordBlock(block);
|
|
725
|
+
if (controller.signal.aborted)
|
|
726
|
+
return;
|
|
727
|
+
// Synthesize thinking.started before non-assistant blocks. After
|
|
728
|
+
// we've seen any assistant_text, only `tool_use` may re-enter
|
|
729
|
+
// thinking — terminal markers like `system`/`other` (codex
|
|
730
|
+
// `turn.completed`, claude `result`) would otherwise flicker
|
|
731
|
+
// "Thinking…" right after the final answer.
|
|
732
|
+
if (!thinkingActive && block.kind !== "assistant_text") {
|
|
733
|
+
const allowed = !sawAssistantText || block.kind === "tool_use";
|
|
734
|
+
if (allowed) {
|
|
735
|
+
thinkingActive = true;
|
|
736
|
+
sendThinkingMarker("started", undefined, "dispatcher");
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
// Once assistant prose lands, the user is reading the answer — exit
|
|
740
|
+
// thinking. Frontend hides "Thinking..." once any assistant_text
|
|
741
|
+
// block has flushed; we just keep our internal flag aligned.
|
|
742
|
+
if (block.kind === "assistant_text") {
|
|
743
|
+
thinkingActive = false;
|
|
744
|
+
sawAssistantText = true;
|
|
745
|
+
}
|
|
746
|
+
forwardBlockToChannel(block);
|
|
747
|
+
}
|
|
748
|
+
: undefined;
|
|
749
|
+
// Helper used by terminal paths (success / timeout / error) to ensure
|
|
750
|
+
// the frontend doesn't get stuck in "Thinking..." when no assistant_text
|
|
751
|
+
// ever lands. Skips on cancel-previous because the superseder will run
|
|
752
|
+
// its own typing/thinking sequence.
|
|
753
|
+
const finalizeThinkingIfActive = () => {
|
|
754
|
+
if (!canStream || !thinkingActive)
|
|
755
|
+
return;
|
|
756
|
+
const supersededByCancel = controller.signal.aborted && !slot.timedOut;
|
|
757
|
+
if (supersededByCancel)
|
|
758
|
+
return;
|
|
759
|
+
thinkingActive = false;
|
|
760
|
+
sendThinkingMarker("stopped", undefined, "dispatcher");
|
|
761
|
+
};
|
|
762
|
+
// Eagerly fire typing.started before runtime.run so the user sees
|
|
763
|
+
// "agent is responding" within ~one round-trip even if the runtime takes
|
|
764
|
+
// seconds before its first block.
|
|
765
|
+
fireTypingIfNeeded();
|
|
574
766
|
// Compute systemContext right before dispatch. The builder must NOT block
|
|
575
767
|
// the turn on failure — log and continue so a flaky memory read can't
|
|
576
768
|
// silence the agent.
|
|
@@ -605,6 +797,7 @@ export class Dispatcher {
|
|
|
605
797
|
trustLevel,
|
|
606
798
|
systemContext,
|
|
607
799
|
onBlock,
|
|
800
|
+
onStatus,
|
|
608
801
|
gateway: route.gateway,
|
|
609
802
|
});
|
|
610
803
|
}
|
|
@@ -841,6 +1034,11 @@ export class Dispatcher {
|
|
|
841
1034
|
});
|
|
842
1035
|
}
|
|
843
1036
|
finally {
|
|
1037
|
+
// Emit a final thinking.stopped on terminal paths so the frontend
|
|
1038
|
+
// never sticks at "Thinking..." when no assistant_text ever landed
|
|
1039
|
+
// (timeout, error, gated reply). Skipped on cancel-previous: the
|
|
1040
|
+
// superseder is about to run its own typing/thinking lifecycle.
|
|
1041
|
+
finalizeThinkingIfActive();
|
|
844
1042
|
// Clear slot ownership AFTER the reply has been sent (or skipped).
|
|
845
1043
|
// Only then do cancel-previous arrivals stop finding this slot — which
|
|
846
1044
|
// is exactly what we want: while we're in the post-runtime window, a
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { RuntimeAdapter, RuntimeProbeResult, RuntimeRunOptions, RuntimeRunResult, StreamBlock } from "../types.js";
|
|
1
|
+
import type { RuntimeAdapter, RuntimeProbeResult, RuntimeRunOptions, RuntimeRunResult, RuntimeStatusEvent, StreamBlock } from "../types.js";
|
|
2
2
|
/** ACP protocol version this client targets. */
|
|
3
3
|
export declare const ACP_PROTOCOL_VERSION = 1;
|
|
4
4
|
export interface AcpInitializeResult {
|
|
@@ -66,6 +66,12 @@ export interface AcpUpdateCtx {
|
|
|
66
66
|
appendAssistantText(text: string): void;
|
|
67
67
|
/** Forward a normalized StreamBlock to `opts.onBlock`. */
|
|
68
68
|
emitBlock(block: StreamBlock): void;
|
|
69
|
+
/**
|
|
70
|
+
* Forward a runtime status event (typing / thinking) to the dispatcher.
|
|
71
|
+
* Useful for ACP `session/update` shapes that signal "agent is busy" but
|
|
72
|
+
* carry no displayable content (e.g. thought chunks, tool progress).
|
|
73
|
+
*/
|
|
74
|
+
emitStatus(event: RuntimeStatusEvent): void;
|
|
69
75
|
/** 1-based sequence within this turn. */
|
|
70
76
|
seq: number;
|
|
71
77
|
}
|
|
@@ -267,6 +267,14 @@ export class AcpRuntimeAdapter {
|
|
|
267
267
|
this.onUpdate(params, {
|
|
268
268
|
appendAssistantText,
|
|
269
269
|
emitBlock: (b) => opts.onBlock?.(b),
|
|
270
|
+
emitStatus: (e) => {
|
|
271
|
+
try {
|
|
272
|
+
opts.onStatus?.(e);
|
|
273
|
+
}
|
|
274
|
+
catch (err) {
|
|
275
|
+
log.warn(`${this.id} onStatus threw`, { err: String(err) });
|
|
276
|
+
}
|
|
277
|
+
},
|
|
270
278
|
seq,
|
|
271
279
|
});
|
|
272
280
|
}
|
|
@@ -335,6 +343,17 @@ export class AcpRuntimeAdapter {
|
|
|
335
343
|
if (stopReason === "refusal" || stopReason === "error") {
|
|
336
344
|
state.errorText = state.errorText ?? `prompt stopped: ${stopReason}`;
|
|
337
345
|
}
|
|
346
|
+
// Tell the dispatcher the runtime has finished its reasoning loop —
|
|
347
|
+
// important for turns that ended without an `agent_message_chunk`
|
|
348
|
+
// (tool-only side effect, refusal, error). The dispatcher's finally
|
|
349
|
+
// block also emits a final thinking.stopped, but firing here delivers
|
|
350
|
+
// it on the wire before child exit (which can take seconds).
|
|
351
|
+
try {
|
|
352
|
+
opts.onStatus?.({ kind: "thinking", phase: "stopped" });
|
|
353
|
+
}
|
|
354
|
+
catch (err) {
|
|
355
|
+
log.warn(`${this.id} onStatus(prompt-done) threw`, { err: String(err) });
|
|
356
|
+
}
|
|
338
357
|
// Politely close stdin so the server can exit. Some ACP servers shut
|
|
339
358
|
// down on EOF; if not, abort signal will SIGTERM.
|
|
340
359
|
try {
|
|
@@ -117,6 +117,11 @@ export class ClaudeCodeAdapter extends NdjsonStreamAdapter {
|
|
|
117
117
|
}
|
|
118
118
|
handleEvent(raw, ctx) {
|
|
119
119
|
const obj = raw;
|
|
120
|
+
// Emit a thinking lifecycle hint BEFORE the block so the dispatcher's
|
|
121
|
+
// auto-synthesis short-circuits (we provide a labeled event instead).
|
|
122
|
+
const status = claudeStatusEvent(obj);
|
|
123
|
+
if (status)
|
|
124
|
+
ctx.emitStatus(status);
|
|
120
125
|
ctx.emitBlock(normalizeBlock(obj, ctx.seq));
|
|
121
126
|
if (obj.type === "system" && obj.session_id) {
|
|
122
127
|
ctx.state.newSessionId = String(obj.session_id);
|
|
@@ -153,6 +158,35 @@ export class ClaudeCodeAdapter extends NdjsonStreamAdapter {
|
|
|
153
158
|
}
|
|
154
159
|
}
|
|
155
160
|
}
|
|
161
|
+
/**
|
|
162
|
+
* Map a Claude Code stream-json event to a `RuntimeStatusEvent`. We only
|
|
163
|
+
* return events for transitions the dispatcher cannot infer from block kinds
|
|
164
|
+
* alone — the auto-synthesis path covers the unlabeled case.
|
|
165
|
+
*
|
|
166
|
+
* Note: Claude Code's `assistant` events sometimes mix `text` and `tool_use`
|
|
167
|
+
* blocks. When `text` is present we treat it as "thinking stopped"; when
|
|
168
|
+
* `tool_use` is present without `text` we surface the tool name as a label.
|
|
169
|
+
*/
|
|
170
|
+
function claudeStatusEvent(obj) {
|
|
171
|
+
if (obj.type === "system" && obj.subtype === "init") {
|
|
172
|
+
return { kind: "thinking", phase: "started", label: "Starting session" };
|
|
173
|
+
}
|
|
174
|
+
if (obj.type === "assistant" && Array.isArray(obj.message?.content)) {
|
|
175
|
+
const contents = obj.message.content;
|
|
176
|
+
const hasText = contents.some((c) => c?.type === "text" && typeof c.text === "string" && c.text.length > 0);
|
|
177
|
+
if (hasText)
|
|
178
|
+
return { kind: "thinking", phase: "stopped" };
|
|
179
|
+
const tool = contents.find((c) => c?.type === "tool_use");
|
|
180
|
+
if (tool) {
|
|
181
|
+
const name = typeof tool.name === "string" && tool.name ? tool.name : "tool";
|
|
182
|
+
return { kind: "thinking", phase: "updated", label: name };
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (obj.type === "result") {
|
|
186
|
+
return { kind: "thinking", phase: "stopped" };
|
|
187
|
+
}
|
|
188
|
+
return undefined;
|
|
189
|
+
}
|
|
156
190
|
function normalizeBlock(obj, seq) {
|
|
157
191
|
let kind = "other";
|
|
158
192
|
if (obj?.type === "assistant") {
|
|
@@ -208,6 +208,12 @@ export class CodexAdapter extends NdjsonStreamAdapter {
|
|
|
208
208
|
}
|
|
209
209
|
handleEvent(raw, ctx) {
|
|
210
210
|
const obj = raw;
|
|
211
|
+
// Emit a thinking lifecycle hint BEFORE the block so the dispatcher's
|
|
212
|
+
// auto-synthesis short-circuits (we provide a labeled event instead).
|
|
213
|
+
// Conservative mapping per design doc §"Runtime adapter 映射".
|
|
214
|
+
const status = codexStatusEvent(obj);
|
|
215
|
+
if (status)
|
|
216
|
+
ctx.emitStatus(status);
|
|
211
217
|
ctx.emitBlock(normalizeBlock(obj, ctx.seq));
|
|
212
218
|
// Persist the thread_id so the next turn on this session key resumes
|
|
213
219
|
// instead of spawning fresh. Safe now that systemContext lives in
|
|
@@ -240,6 +246,50 @@ export class CodexAdapter extends NdjsonStreamAdapter {
|
|
|
240
246
|
}
|
|
241
247
|
}
|
|
242
248
|
}
|
|
249
|
+
/**
|
|
250
|
+
* Map a Codex JSONL event to a `RuntimeStatusEvent` for the dispatcher's
|
|
251
|
+
* thinking UI. Returns `undefined` for events that should not influence
|
|
252
|
+
* status (the dispatcher already synthesizes a generic marker on the first
|
|
253
|
+
* non-assistant block, so we only override when a label is meaningful).
|
|
254
|
+
*/
|
|
255
|
+
function codexStatusEvent(obj) {
|
|
256
|
+
if (obj.type === "thread.started") {
|
|
257
|
+
return { kind: "thinking", phase: "started", label: "Starting session" };
|
|
258
|
+
}
|
|
259
|
+
if (obj.type === "turn.started") {
|
|
260
|
+
return { kind: "thinking", phase: "started", label: "Thinking" };
|
|
261
|
+
}
|
|
262
|
+
if (obj.type === "item.started" && typeof obj.item?.type === "string") {
|
|
263
|
+
const tool = obj.item.type;
|
|
264
|
+
if (tool === "command_execution" ||
|
|
265
|
+
tool === "file_change" ||
|
|
266
|
+
tool === "mcp_tool_call" ||
|
|
267
|
+
tool === "web_search") {
|
|
268
|
+
return { kind: "thinking", phase: "updated", label: codexToolLabel(tool) };
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
if (obj.type === "item.completed" && obj.item?.type === "agent_message") {
|
|
272
|
+
return { kind: "thinking", phase: "stopped" };
|
|
273
|
+
}
|
|
274
|
+
if (obj.type === "turn.completed") {
|
|
275
|
+
return { kind: "thinking", phase: "stopped" };
|
|
276
|
+
}
|
|
277
|
+
return undefined;
|
|
278
|
+
}
|
|
279
|
+
function codexToolLabel(tool) {
|
|
280
|
+
switch (tool) {
|
|
281
|
+
case "command_execution":
|
|
282
|
+
return "Running command";
|
|
283
|
+
case "file_change":
|
|
284
|
+
return "Editing files";
|
|
285
|
+
case "mcp_tool_call":
|
|
286
|
+
return "Calling tool";
|
|
287
|
+
case "web_search":
|
|
288
|
+
return "Searching web";
|
|
289
|
+
default:
|
|
290
|
+
return tool;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
243
293
|
/**
|
|
244
294
|
* Atomically overwrite `<CODEX_HOME>/AGENTS.md` with `systemContext`. codex
|
|
245
295
|
* reads this file at process start, so the write must complete before spawn.
|
|
@@ -71,10 +71,15 @@ export declare class HermesAgentAdapter extends AcpRuntimeAdapter {
|
|
|
71
71
|
* assistant text. We surface the common shapes that hermes emits:
|
|
72
72
|
* - `agent_message_chunk` / `user_message_chunk` content blocks
|
|
73
73
|
* - `tool_call` / `tool_call_update`
|
|
74
|
-
* - `agent_thought_chunk`
|
|
74
|
+
* - `agent_thought_chunk` (status-only — see below)
|
|
75
75
|
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
76
|
+
* `agent_thought_chunk` deliberately maps to ONLY a `thinking.updated`
|
|
77
|
+
* status event, NOT a block: the underlying ACP payload has no `subtype`
|
|
78
|
+
* / `session_id` / `model` fields that `normalizeBlockForHub("system")`
|
|
79
|
+
* would render, so emitting a `kind:"system"` block here just produces an
|
|
80
|
+
* empty payload alongside the labeled thinking frame. Anything else is
|
|
81
|
+
* forwarded as `kind: "other"` so subclasses / downstream channels can
|
|
82
|
+
* introspect.
|
|
78
83
|
*/
|
|
79
84
|
protected onUpdate(params: AcpUpdateParams, ctx: AcpUpdateCtx): void;
|
|
80
85
|
/**
|
|
@@ -153,32 +153,45 @@ export class HermesAgentAdapter extends AcpRuntimeAdapter {
|
|
|
153
153
|
* assistant text. We surface the common shapes that hermes emits:
|
|
154
154
|
* - `agent_message_chunk` / `user_message_chunk` content blocks
|
|
155
155
|
* - `tool_call` / `tool_call_update`
|
|
156
|
-
* - `agent_thought_chunk`
|
|
156
|
+
* - `agent_thought_chunk` (status-only — see below)
|
|
157
157
|
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
158
|
+
* `agent_thought_chunk` deliberately maps to ONLY a `thinking.updated`
|
|
159
|
+
* status event, NOT a block: the underlying ACP payload has no `subtype`
|
|
160
|
+
* / `session_id` / `model` fields that `normalizeBlockForHub("system")`
|
|
161
|
+
* would render, so emitting a `kind:"system"` block here just produces an
|
|
162
|
+
* empty payload alongside the labeled thinking frame. Anything else is
|
|
163
|
+
* forwarded as `kind: "other"` so subclasses / downstream channels can
|
|
164
|
+
* introspect.
|
|
160
165
|
*/
|
|
161
166
|
onUpdate(params, ctx) {
|
|
162
167
|
const update = params.update ?? {};
|
|
163
168
|
const kind = typeof update.sessionUpdate === "string" ? update.sessionUpdate : "";
|
|
169
|
+
if (kind === "agent_thought_chunk") {
|
|
170
|
+
ctx.emitStatus({ kind: "thinking", phase: "updated", label: "Thinking" });
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
164
173
|
let blockKind = "other";
|
|
174
|
+
let assistantTextSeen = false;
|
|
165
175
|
if (kind === "agent_message_chunk") {
|
|
166
176
|
const content = update
|
|
167
177
|
.content;
|
|
168
178
|
if (content && content.type === "text" && typeof content.text === "string") {
|
|
169
179
|
ctx.appendAssistantText(content.text);
|
|
180
|
+
assistantTextSeen = content.text.length > 0;
|
|
170
181
|
}
|
|
171
182
|
blockKind = "assistant_text";
|
|
172
183
|
}
|
|
173
|
-
else if (kind === "agent_thought_chunk") {
|
|
174
|
-
blockKind = "system";
|
|
175
|
-
}
|
|
176
184
|
else if (kind === "tool_call" || kind === "tool_call_update") {
|
|
177
185
|
blockKind = "tool_use";
|
|
178
186
|
}
|
|
179
187
|
else if (kind === "user_message_chunk") {
|
|
180
188
|
blockKind = "other";
|
|
181
189
|
}
|
|
190
|
+
// Status hint BEFORE the block so the dispatcher's auto-synthesis sees a
|
|
191
|
+
// labeled `thinking.updated`/`stopped` instead of a bare `started`.
|
|
192
|
+
const status = hermesStatusEvent(kind, update, assistantTextSeen);
|
|
193
|
+
if (status)
|
|
194
|
+
ctx.emitStatus(status);
|
|
182
195
|
ctx.emitBlock({ raw: params, kind: blockKind, seq: ctx.seq });
|
|
183
196
|
}
|
|
184
197
|
/**
|
|
@@ -202,3 +215,20 @@ export class HermesAgentAdapter extends AcpRuntimeAdapter {
|
|
|
202
215
|
return { outcome: { outcome: "cancelled" } };
|
|
203
216
|
}
|
|
204
217
|
}
|
|
218
|
+
/**
|
|
219
|
+
* Map an ACP `session/update` payload to a `RuntimeStatusEvent`. We only
|
|
220
|
+
* return events that add a label or convey a transition the dispatcher
|
|
221
|
+
* cannot infer from block kinds — the auto-synthesis path covers the rest.
|
|
222
|
+
*/
|
|
223
|
+
function hermesStatusEvent(kind, update, assistantTextSeen) {
|
|
224
|
+
// `agent_thought_chunk` is handled inline in `onUpdate` (status-only path).
|
|
225
|
+
if (kind === "tool_call" || kind === "tool_call_update") {
|
|
226
|
+
const tool = update.toolCall;
|
|
227
|
+
const name = typeof tool?.name === "string" && tool.name ? tool.name : "tool";
|
|
228
|
+
return { kind: "thinking", phase: "updated", label: name };
|
|
229
|
+
}
|
|
230
|
+
if (kind === "agent_message_chunk" && assistantTextSeen) {
|
|
231
|
+
return { kind: "thinking", phase: "stopped" };
|
|
232
|
+
}
|
|
233
|
+
return undefined;
|
|
234
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { RuntimeAdapter, RuntimeProbeResult, RuntimeRunOptions, RuntimeRunResult, StreamBlock } from "../types.js";
|
|
1
|
+
import type { RuntimeAdapter, RuntimeProbeResult, RuntimeRunOptions, RuntimeRunResult, RuntimeStatusEvent, StreamBlock } from "../types.js";
|
|
2
2
|
/**
|
|
3
3
|
* Mutable state threaded through event callbacks while a single turn runs.
|
|
4
4
|
* The base class reads these fields to assemble the final RuntimeRunResult.
|
|
@@ -29,6 +29,13 @@ export interface NdjsonEventCtx {
|
|
|
29
29
|
* Subclasses should use this instead of `state.assistantTextChunks.push(...)`.
|
|
30
30
|
*/
|
|
31
31
|
appendAssistantText: (text: string) => void;
|
|
32
|
+
/**
|
|
33
|
+
* Forward a runtime status event (typing / thinking) to the dispatcher.
|
|
34
|
+
* Adapters should call this when an event reveals the runtime's lifecycle
|
|
35
|
+
* stage before any visible block lands — e.g. Codex `thread.started`,
|
|
36
|
+
* Claude Code `system` init. Errors thrown here are swallowed.
|
|
37
|
+
*/
|
|
38
|
+
emitStatus: (event: RuntimeStatusEvent) => void;
|
|
32
39
|
}
|
|
33
40
|
/** Base class for runtime adapters that drive a CLI emitting newline-delimited JSON. */
|
|
34
41
|
export declare abstract class NdjsonStreamAdapter implements RuntimeAdapter {
|
|
@@ -136,6 +136,14 @@ export class NdjsonStreamAdapter {
|
|
|
136
136
|
seq,
|
|
137
137
|
emitBlock: (b) => opts.onBlock?.(b),
|
|
138
138
|
appendAssistantText,
|
|
139
|
+
emitStatus: (e) => {
|
|
140
|
+
try {
|
|
141
|
+
opts.onStatus?.(e);
|
|
142
|
+
}
|
|
143
|
+
catch (err) {
|
|
144
|
+
log.warn(`${this.id} onStatus threw`, { err: String(err) });
|
|
145
|
+
}
|
|
146
|
+
},
|
|
139
147
|
});
|
|
140
148
|
}
|
|
141
149
|
catch (err) {
|