@botcord/daemon 0.2.13 → 0.2.15
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-workspace.js +47 -1
- 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__/agent-workspace.test.ts +25 -0
- package/src/__tests__/provision.test.ts +68 -1
- package/src/agent-workspace.ts +47 -0
- 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
|
@@ -15,6 +15,7 @@ import type {
|
|
|
15
15
|
ChannelStatusSnapshot,
|
|
16
16
|
ChannelStopContext,
|
|
17
17
|
ChannelStreamBlockContext,
|
|
18
|
+
ChannelTypingContext,
|
|
18
19
|
GatewayInboundEnvelope,
|
|
19
20
|
GatewayInboundMessage,
|
|
20
21
|
GatewayLogger,
|
|
@@ -690,6 +691,32 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
|
|
|
690
691
|
}
|
|
691
692
|
},
|
|
692
693
|
|
|
694
|
+
async typing(ctx: ChannelTypingContext): Promise<void> {
|
|
695
|
+
const client = ensureClient();
|
|
696
|
+
const hubUrl = options.hubBaseUrl ?? client.getHubUrl();
|
|
697
|
+
try {
|
|
698
|
+
const token = await client.ensureToken();
|
|
699
|
+
const resp = await fetch(`${hubUrl}/hub/typing`, {
|
|
700
|
+
method: "POST",
|
|
701
|
+
headers: {
|
|
702
|
+
"Content-Type": "application/json",
|
|
703
|
+
Authorization: `Bearer ${token}`,
|
|
704
|
+
},
|
|
705
|
+
body: JSON.stringify({ room_id: ctx.conversationId }),
|
|
706
|
+
signal: AbortSignal.timeout(10_000),
|
|
707
|
+
});
|
|
708
|
+
if (!resp.ok && resp.status !== 204) {
|
|
709
|
+
const body = await resp.text().catch(() => "");
|
|
710
|
+
ctx.log.warn("botcord typing non-ok", {
|
|
711
|
+
status: resp.status,
|
|
712
|
+
body: body.slice(0, 200),
|
|
713
|
+
});
|
|
714
|
+
}
|
|
715
|
+
} catch (err) {
|
|
716
|
+
ctx.log.warn("botcord typing failed", { err: String(err) });
|
|
717
|
+
}
|
|
718
|
+
},
|
|
719
|
+
|
|
693
720
|
status(): ChannelStatusSnapshot {
|
|
694
721
|
return { ...statusSnapshot };
|
|
695
722
|
},
|
|
@@ -777,6 +804,17 @@ function normalizeBlockForHub(
|
|
|
777
804
|
return { kind: "system", seq, payload };
|
|
778
805
|
}
|
|
779
806
|
|
|
807
|
+
if (kind === "thinking") {
|
|
808
|
+
// Daemon-synthesized lifecycle marker. `raw` carries `{ phase, label?, source? }`
|
|
809
|
+
// — see Dispatcher's status forwarding. The frontend uses `phase` to decide
|
|
810
|
+
// whether to enter/leave the compact "Thinking..." UI; `label` is a free-form
|
|
811
|
+
// human hint (e.g. "Searching web"). Treat as untrusted text — never inject.
|
|
812
|
+
if (typeof raw?.phase === "string") payload.phase = raw.phase;
|
|
813
|
+
if (typeof raw?.label === "string") payload.label = raw.label;
|
|
814
|
+
if (typeof raw?.source === "string") payload.source = raw.source;
|
|
815
|
+
return { kind: "thinking", seq, payload };
|
|
816
|
+
}
|
|
817
|
+
|
|
780
818
|
// "other" — e.g. Claude Code `type:"result"` end-of-turn summary.
|
|
781
819
|
if (raw?.type === "result") {
|
|
782
820
|
if (typeof raw.result === "string") payload.text = raw.result;
|
|
@@ -21,6 +21,7 @@ import type {
|
|
|
21
21
|
OutboundObserver,
|
|
22
22
|
QueueMode,
|
|
23
23
|
RuntimeAdapter,
|
|
24
|
+
RuntimeStatusEvent,
|
|
24
25
|
StreamBlock,
|
|
25
26
|
SystemContextBuilder,
|
|
26
27
|
TurnStatusSnapshot,
|
|
@@ -47,6 +48,16 @@ const MAX_BATCH_BUFFER_ENTRIES = 40;
|
|
|
47
48
|
*/
|
|
48
49
|
const MAX_BATCH_BUFFER_CHARS = 16000;
|
|
49
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Per-(accountId, conversationId) cooldown between successive `/hub/typing`
|
|
53
|
+
* pings. Hub rate-limits to 20 typing/min per agent (backend hub.py:1675);
|
|
54
|
+
* cancel-previous bursts on a fast user can otherwise trip 429 silently.
|
|
55
|
+
*/
|
|
56
|
+
const TYPING_DEBOUNCE_MS = 2000;
|
|
57
|
+
|
|
58
|
+
/** LRU cap on the typing-recency map so long-running daemons don't grow unbounded. */
|
|
59
|
+
const TYPING_RECENCY_CAP = 1024;
|
|
60
|
+
|
|
50
61
|
/** Factory signature for building a runtime adapter at turn dispatch time. */
|
|
51
62
|
export type RuntimeFactory = (
|
|
52
63
|
runtimeId: string,
|
|
@@ -214,6 +225,12 @@ export class Dispatcher {
|
|
|
214
225
|
private readonly resolveHubUrl?: (accountId: string) => string | undefined;
|
|
215
226
|
private readonly transcript: TranscriptWriter;
|
|
216
227
|
private readonly queues: Map<string, QueueState> = new Map();
|
|
228
|
+
/**
|
|
229
|
+
* Last `/hub/typing` ping timestamp per (accountId, conversationId).
|
|
230
|
+
* Used to debounce cancel-previous bursts so we don't trip Hub's 20/min
|
|
231
|
+
* rate limit. True LRU (delete + set on access) capped at TYPING_RECENCY_CAP.
|
|
232
|
+
*/
|
|
233
|
+
private readonly recentTypingPings: Map<string, number> = new Map();
|
|
217
234
|
|
|
218
235
|
constructor(opts: DispatcherOptions) {
|
|
219
236
|
this.config = opts.config;
|
|
@@ -757,27 +774,206 @@ export class Dispatcher {
|
|
|
757
774
|
}
|
|
758
775
|
slot.blocks.push(summary);
|
|
759
776
|
};
|
|
760
|
-
|
|
777
|
+
|
|
778
|
+
// Owner-chat lifecycle state for typing/thinking. The dispatcher is the
|
|
779
|
+
// only component that sees turn boundaries + channel capabilities + trace
|
|
780
|
+
// ids together, so it owns the收束: once `typing.started` fires we never
|
|
781
|
+
// re-fire it within this turn (frontend clears via stream/message
|
|
782
|
+
// arrival), and `thinking` is auto-synthesized on the first non-assistant
|
|
783
|
+
// block so adapters that emit nothing-but-blocks still drive the
|
|
784
|
+
// "Thinking..." UI.
|
|
785
|
+
let typingFired = false;
|
|
786
|
+
let thinkingActive = false;
|
|
787
|
+
/**
|
|
788
|
+
* Sticky: once we've forwarded any assistant_text to the wire, we stop
|
|
789
|
+
* auto-synthesizing thinking on plain `system`/`other` blocks. This
|
|
790
|
+
* prevents the post-prose flicker caused by Codex's `turn.completed` /
|
|
791
|
+
* Claude Code's `result` (both arrive as system/other AFTER the prose).
|
|
792
|
+
* `tool_use` is the explicit exception — agents that legitimately go
|
|
793
|
+
* back to work after a partial answer should still drive "Thinking…".
|
|
794
|
+
*/
|
|
795
|
+
let sawAssistantText = false;
|
|
796
|
+
let blocksSent = 0;
|
|
797
|
+
|
|
798
|
+
const forwardBlockToChannel = canStream
|
|
761
799
|
? (block: StreamBlock) => {
|
|
762
|
-
|
|
763
|
-
//
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
800
|
+
// Re-sequence at the wire boundary so synthesized thinking blocks
|
|
801
|
+
// interleave cleanly with adapter-emitted blocks; adapters keep
|
|
802
|
+
// their own per-turn seq for tracing/logging only.
|
|
803
|
+
blocksSent += 1;
|
|
804
|
+
const ctx = {
|
|
805
|
+
traceId: traceId!,
|
|
806
|
+
accountId: msg.accountId,
|
|
807
|
+
conversationId: msg.conversation.id,
|
|
808
|
+
block: { ...block, seq: blocksSent },
|
|
809
|
+
log: this.log,
|
|
810
|
+
};
|
|
811
|
+
// Coerce a synchronous throw from a non-async adapter into the same
|
|
812
|
+
// warn path as an async rejection so a buggy channel never tears
|
|
813
|
+
// down the turn (the adapter contract is fire-and-forget).
|
|
814
|
+
try {
|
|
815
|
+
const ret = channel.streamBlock!(ctx);
|
|
816
|
+
if (ret && typeof (ret as Promise<void>).catch === "function") {
|
|
817
|
+
(ret as Promise<void>).catch((err) => {
|
|
818
|
+
this.log.warn("dispatcher: streamBlock failed", {
|
|
819
|
+
traceId,
|
|
820
|
+
error: err instanceof Error ? err.message : String(err),
|
|
821
|
+
});
|
|
776
822
|
});
|
|
823
|
+
}
|
|
824
|
+
} catch (err) {
|
|
825
|
+
this.log.warn("dispatcher: streamBlock threw", {
|
|
826
|
+
traceId,
|
|
827
|
+
error: err instanceof Error ? err.message : String(err),
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
: undefined;
|
|
832
|
+
|
|
833
|
+
const sendThinkingMarker = (
|
|
834
|
+
phase: "started" | "updated" | "stopped",
|
|
835
|
+
label: string | undefined,
|
|
836
|
+
source: "dispatcher" | "runtime",
|
|
837
|
+
): void => {
|
|
838
|
+
if (!forwardBlockToChannel) return;
|
|
839
|
+
const raw: Record<string, unknown> = { phase, source };
|
|
840
|
+
if (label) raw.label = label;
|
|
841
|
+
const synth: StreamBlock = { raw, kind: "thinking", seq: 0 };
|
|
842
|
+
// Intentionally NOT `recordBlock(synth)` — the transcript stays
|
|
843
|
+
// adapter-truth so downstream log consumers see only what the runtime
|
|
844
|
+
// actually emitted, not daemon-synthesized lifecycle frames.
|
|
845
|
+
forwardBlockToChannel(synth);
|
|
846
|
+
};
|
|
847
|
+
|
|
848
|
+
const fireTypingIfNeeded = (): void => {
|
|
849
|
+
if (!canStream || typingFired || typeof channel.typing !== "function") return;
|
|
850
|
+
typingFired = true;
|
|
851
|
+
const key = `${msg.accountId}:${msg.conversation.id}`;
|
|
852
|
+
const now = Date.now();
|
|
853
|
+
const last = this.recentTypingPings.get(key);
|
|
854
|
+
if (last !== undefined && now - last < TYPING_DEBOUNCE_MS) {
|
|
855
|
+
// Within the debounce window — Hub's 2s dedup absorbs this. The
|
|
856
|
+
// window thins out cancel-previous bursts; it does NOT fully
|
|
857
|
+
// prevent 429s when many active rooms ping concurrently, so the
|
|
858
|
+
// try/catch around `channel.typing()` is what actually keeps the
|
|
859
|
+
// turn alive on rate-limit (backend hub.py:1675).
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
862
|
+
// True LRU: delete-then-set bumps the entry to the tail of the Map
|
|
863
|
+
// insertion order, so chronically active conversations never get
|
|
864
|
+
// evicted by an unrelated newcomer at the cap.
|
|
865
|
+
this.recentTypingPings.delete(key);
|
|
866
|
+
this.recentTypingPings.set(key, now);
|
|
867
|
+
if (this.recentTypingPings.size > TYPING_RECENCY_CAP) {
|
|
868
|
+
const oldest = this.recentTypingPings.keys().next().value;
|
|
869
|
+
if (oldest !== undefined) this.recentTypingPings.delete(oldest);
|
|
870
|
+
}
|
|
871
|
+
const ctx = {
|
|
872
|
+
traceId: traceId!,
|
|
873
|
+
accountId: msg.accountId,
|
|
874
|
+
conversationId: msg.conversation.id,
|
|
875
|
+
log: this.log,
|
|
876
|
+
};
|
|
877
|
+
try {
|
|
878
|
+
const ret = channel.typing!(ctx);
|
|
879
|
+
if (ret && typeof (ret as Promise<void>).catch === "function") {
|
|
880
|
+
(ret as Promise<void>).catch((err) => {
|
|
881
|
+
this.log.warn("dispatcher: channel.typing failed", {
|
|
882
|
+
traceId,
|
|
883
|
+
error: err instanceof Error ? err.message : String(err),
|
|
777
884
|
});
|
|
885
|
+
});
|
|
886
|
+
}
|
|
887
|
+
} catch (err) {
|
|
888
|
+
this.log.warn("dispatcher: channel.typing threw", {
|
|
889
|
+
traceId,
|
|
890
|
+
error: err instanceof Error ? err.message : String(err),
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
};
|
|
894
|
+
|
|
895
|
+
const onStatus = canStream
|
|
896
|
+
? (event: RuntimeStatusEvent) => {
|
|
897
|
+
// Drop runtime callbacks after this turn's controller aborts —
|
|
898
|
+
// NDJSON/ACP adapters keep parsing stdout until the child exits
|
|
899
|
+
// (up to KILL_GRACE_MS after SIGTERM), so without this guard a
|
|
900
|
+
// superseded turn leaks frames to the new turn's UI.
|
|
901
|
+
if (controller.signal.aborted) return;
|
|
902
|
+
if (event.kind === "typing") {
|
|
903
|
+
// `/hub/typing` has no stopped semantic — frontend self-clears on
|
|
904
|
+
// stream/message arrival. typing.stopped is observed for daemon
|
|
905
|
+
// bookkeeping only (currently a no-op; kept for telemetry).
|
|
906
|
+
if (event.phase === "started") fireTypingIfNeeded();
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
if (event.phase === "stopped") {
|
|
910
|
+
// Forward to wire ONLY if we previously announced thinking — that
|
|
911
|
+
// way `finalizeThinkingIfActive` doesn't double-emit, and adapters
|
|
912
|
+
// that signal terminal closure earlier than child exit (e.g.
|
|
913
|
+
// acp-stream's prompt-done) reach the frontend without waiting.
|
|
914
|
+
if (thinkingActive) {
|
|
915
|
+
sendThinkingMarker("stopped", event.label, "runtime");
|
|
916
|
+
}
|
|
917
|
+
thinkingActive = false;
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
// Runtime-emitted thinking.started/.updated is trusted unconditionally:
|
|
921
|
+
// we deliberately do NOT apply the `sawAssistantText` sticky guard
|
|
922
|
+
// here, only to dispatcher-synthesized starts. Adapters opting into
|
|
923
|
+
// explicit status events accept the responsibility of driving a
|
|
924
|
+
// sensible lifecycle (don't fire .started after the final answer).
|
|
925
|
+
thinkingActive = true;
|
|
926
|
+
sendThinkingMarker(event.phase, event.label, "runtime");
|
|
778
927
|
}
|
|
779
928
|
: undefined;
|
|
780
929
|
|
|
930
|
+
const onBlock = canStream
|
|
931
|
+
? (block: StreamBlock) => {
|
|
932
|
+
// Always record adapter-emitted blocks for transcript fidelity, even
|
|
933
|
+
// after abort — the transcript reflects what the runtime emitted,
|
|
934
|
+
// not what the dispatcher chose to forward.
|
|
935
|
+
recordBlock(block);
|
|
936
|
+
if (controller.signal.aborted) return;
|
|
937
|
+
// Synthesize thinking.started before non-assistant blocks. After
|
|
938
|
+
// we've seen any assistant_text, only `tool_use` may re-enter
|
|
939
|
+
// thinking — terminal markers like `system`/`other` (codex
|
|
940
|
+
// `turn.completed`, claude `result`) would otherwise flicker
|
|
941
|
+
// "Thinking…" right after the final answer.
|
|
942
|
+
if (!thinkingActive && block.kind !== "assistant_text") {
|
|
943
|
+
const allowed = !sawAssistantText || block.kind === "tool_use";
|
|
944
|
+
if (allowed) {
|
|
945
|
+
thinkingActive = true;
|
|
946
|
+
sendThinkingMarker("started", undefined, "dispatcher");
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
// Once assistant prose lands, the user is reading the answer — exit
|
|
950
|
+
// thinking. Frontend hides "Thinking..." once any assistant_text
|
|
951
|
+
// block has flushed; we just keep our internal flag aligned.
|
|
952
|
+
if (block.kind === "assistant_text") {
|
|
953
|
+
thinkingActive = false;
|
|
954
|
+
sawAssistantText = true;
|
|
955
|
+
}
|
|
956
|
+
forwardBlockToChannel!(block);
|
|
957
|
+
}
|
|
958
|
+
: undefined;
|
|
959
|
+
|
|
960
|
+
// Helper used by terminal paths (success / timeout / error) to ensure
|
|
961
|
+
// the frontend doesn't get stuck in "Thinking..." when no assistant_text
|
|
962
|
+
// ever lands. Skips on cancel-previous because the superseder will run
|
|
963
|
+
// its own typing/thinking sequence.
|
|
964
|
+
const finalizeThinkingIfActive = (): void => {
|
|
965
|
+
if (!canStream || !thinkingActive) return;
|
|
966
|
+
const supersededByCancel = controller.signal.aborted && !slot.timedOut;
|
|
967
|
+
if (supersededByCancel) return;
|
|
968
|
+
thinkingActive = false;
|
|
969
|
+
sendThinkingMarker("stopped", undefined, "dispatcher");
|
|
970
|
+
};
|
|
971
|
+
|
|
972
|
+
// Eagerly fire typing.started before runtime.run so the user sees
|
|
973
|
+
// "agent is responding" within ~one round-trip even if the runtime takes
|
|
974
|
+
// seconds before its first block.
|
|
975
|
+
fireTypingIfNeeded();
|
|
976
|
+
|
|
781
977
|
// Compute systemContext right before dispatch. The builder must NOT block
|
|
782
978
|
// the turn on failure — log and continue so a flaky memory read can't
|
|
783
979
|
// silence the agent.
|
|
@@ -812,6 +1008,7 @@ export class Dispatcher {
|
|
|
812
1008
|
trustLevel,
|
|
813
1009
|
systemContext,
|
|
814
1010
|
onBlock,
|
|
1011
|
+
onStatus,
|
|
815
1012
|
gateway: route.gateway,
|
|
816
1013
|
});
|
|
817
1014
|
} catch (err) {
|
|
@@ -1053,6 +1250,11 @@ export class Dispatcher {
|
|
|
1053
1250
|
blocks: slot.blocks,
|
|
1054
1251
|
});
|
|
1055
1252
|
} finally {
|
|
1253
|
+
// Emit a final thinking.stopped on terminal paths so the frontend
|
|
1254
|
+
// never sticks at "Thinking..." when no assistant_text ever landed
|
|
1255
|
+
// (timeout, error, gated reply). Skipped on cancel-previous: the
|
|
1256
|
+
// superseder is about to run its own typing/thinking lifecycle.
|
|
1257
|
+
finalizeThinkingIfActive();
|
|
1056
1258
|
// Clear slot ownership AFTER the reply has been sent (or skipped).
|
|
1057
1259
|
// Only then do cancel-previous arrivals stop finding this slot — which
|
|
1058
1260
|
// is exactly what we want: while we're in the post-runtime window, a
|
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
RuntimeProbeResult,
|
|
6
6
|
RuntimeRunOptions,
|
|
7
7
|
RuntimeRunResult,
|
|
8
|
+
RuntimeStatusEvent,
|
|
8
9
|
StreamBlock,
|
|
9
10
|
} from "../types.js";
|
|
10
11
|
|
|
@@ -85,6 +86,12 @@ export interface AcpUpdateCtx {
|
|
|
85
86
|
appendAssistantText(text: string): void;
|
|
86
87
|
/** Forward a normalized StreamBlock to `opts.onBlock`. */
|
|
87
88
|
emitBlock(block: StreamBlock): void;
|
|
89
|
+
/**
|
|
90
|
+
* Forward a runtime status event (typing / thinking) to the dispatcher.
|
|
91
|
+
* Useful for ACP `session/update` shapes that signal "agent is busy" but
|
|
92
|
+
* carry no displayable content (e.g. thought chunks, tool progress).
|
|
93
|
+
*/
|
|
94
|
+
emitStatus(event: RuntimeStatusEvent): void;
|
|
88
95
|
/** 1-based sequence within this turn. */
|
|
89
96
|
seq: number;
|
|
90
97
|
}
|
|
@@ -381,6 +388,13 @@ export abstract class AcpRuntimeAdapter implements RuntimeAdapter {
|
|
|
381
388
|
this.onUpdate(params as AcpUpdateParams, {
|
|
382
389
|
appendAssistantText,
|
|
383
390
|
emitBlock: (b) => opts.onBlock?.(b),
|
|
391
|
+
emitStatus: (e) => {
|
|
392
|
+
try {
|
|
393
|
+
opts.onStatus?.(e);
|
|
394
|
+
} catch (err) {
|
|
395
|
+
log.warn(`${this.id} onStatus threw`, { err: String(err) });
|
|
396
|
+
}
|
|
397
|
+
},
|
|
384
398
|
seq,
|
|
385
399
|
});
|
|
386
400
|
}
|
|
@@ -466,6 +480,16 @@ export abstract class AcpRuntimeAdapter implements RuntimeAdapter {
|
|
|
466
480
|
if (stopReason === "refusal" || stopReason === "error") {
|
|
467
481
|
state.errorText = state.errorText ?? `prompt stopped: ${stopReason}`;
|
|
468
482
|
}
|
|
483
|
+
// Tell the dispatcher the runtime has finished its reasoning loop —
|
|
484
|
+
// important for turns that ended without an `agent_message_chunk`
|
|
485
|
+
// (tool-only side effect, refusal, error). The dispatcher's finally
|
|
486
|
+
// block also emits a final thinking.stopped, but firing here delivers
|
|
487
|
+
// it on the wire before child exit (which can take seconds).
|
|
488
|
+
try {
|
|
489
|
+
opts.onStatus?.({ kind: "thinking", phase: "stopped" });
|
|
490
|
+
} catch (err) {
|
|
491
|
+
log.warn(`${this.id} onStatus(prompt-done) threw`, { err: String(err) });
|
|
492
|
+
}
|
|
469
493
|
|
|
470
494
|
// Politely close stdin so the server can exit. Some ACP servers shut
|
|
471
495
|
// down on EOF; if not, abort signal will SIGTERM.
|
|
@@ -140,9 +140,14 @@ export class ClaudeCodeAdapter extends NdjsonStreamAdapter {
|
|
|
140
140
|
session_id?: string;
|
|
141
141
|
total_cost_usd?: number;
|
|
142
142
|
result?: string;
|
|
143
|
-
message?: { content?: Array<{ type?: string; text?: string }> };
|
|
143
|
+
message?: { content?: Array<{ type?: string; text?: string; name?: string }> };
|
|
144
144
|
};
|
|
145
145
|
|
|
146
|
+
// Emit a thinking lifecycle hint BEFORE the block so the dispatcher's
|
|
147
|
+
// auto-synthesis short-circuits (we provide a labeled event instead).
|
|
148
|
+
const status = claudeStatusEvent(obj);
|
|
149
|
+
if (status) ctx.emitStatus(status);
|
|
150
|
+
|
|
146
151
|
ctx.emitBlock(normalizeBlock(obj, ctx.seq));
|
|
147
152
|
|
|
148
153
|
if (obj.type === "system" && obj.session_id) {
|
|
@@ -176,6 +181,41 @@ export class ClaudeCodeAdapter extends NdjsonStreamAdapter {
|
|
|
176
181
|
}
|
|
177
182
|
}
|
|
178
183
|
|
|
184
|
+
/**
|
|
185
|
+
* Map a Claude Code stream-json event to a `RuntimeStatusEvent`. We only
|
|
186
|
+
* return events for transitions the dispatcher cannot infer from block kinds
|
|
187
|
+
* alone — the auto-synthesis path covers the unlabeled case.
|
|
188
|
+
*
|
|
189
|
+
* Note: Claude Code's `assistant` events sometimes mix `text` and `tool_use`
|
|
190
|
+
* blocks. When `text` is present we treat it as "thinking stopped"; when
|
|
191
|
+
* `tool_use` is present without `text` we surface the tool name as a label.
|
|
192
|
+
*/
|
|
193
|
+
function claudeStatusEvent(obj: {
|
|
194
|
+
type?: string;
|
|
195
|
+
subtype?: string;
|
|
196
|
+
message?: { content?: Array<{ type?: string; text?: string; name?: string }> };
|
|
197
|
+
}): import("../types.js").RuntimeStatusEvent | undefined {
|
|
198
|
+
if (obj.type === "system" && obj.subtype === "init") {
|
|
199
|
+
return { kind: "thinking", phase: "started", label: "Starting session" };
|
|
200
|
+
}
|
|
201
|
+
if (obj.type === "assistant" && Array.isArray(obj.message?.content)) {
|
|
202
|
+
const contents = obj.message.content;
|
|
203
|
+
const hasText = contents.some(
|
|
204
|
+
(c) => c?.type === "text" && typeof c.text === "string" && c.text.length > 0,
|
|
205
|
+
);
|
|
206
|
+
if (hasText) return { kind: "thinking", phase: "stopped" };
|
|
207
|
+
const tool = contents.find((c) => c?.type === "tool_use");
|
|
208
|
+
if (tool) {
|
|
209
|
+
const name = typeof tool.name === "string" && tool.name ? tool.name : "tool";
|
|
210
|
+
return { kind: "thinking", phase: "updated", label: name };
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
if (obj.type === "result") {
|
|
214
|
+
return { kind: "thinking", phase: "stopped" };
|
|
215
|
+
}
|
|
216
|
+
return undefined;
|
|
217
|
+
}
|
|
218
|
+
|
|
179
219
|
function normalizeBlock(obj: any, seq: number): StreamBlock {
|
|
180
220
|
let kind: StreamBlock["kind"] = "other";
|
|
181
221
|
if (obj?.type === "assistant") {
|
|
@@ -242,6 +242,12 @@ export class CodexAdapter extends NdjsonStreamAdapter {
|
|
|
242
242
|
turn?: { status?: string; error?: { message?: string } };
|
|
243
243
|
};
|
|
244
244
|
|
|
245
|
+
// Emit a thinking lifecycle hint BEFORE the block so the dispatcher's
|
|
246
|
+
// auto-synthesis short-circuits (we provide a labeled event instead).
|
|
247
|
+
// Conservative mapping per design doc §"Runtime adapter 映射".
|
|
248
|
+
const status = codexStatusEvent(obj);
|
|
249
|
+
if (status) ctx.emitStatus(status);
|
|
250
|
+
|
|
245
251
|
ctx.emitBlock(normalizeBlock(obj, ctx.seq));
|
|
246
252
|
|
|
247
253
|
// Persist the thread_id so the next turn on this session key resumes
|
|
@@ -278,6 +284,58 @@ export class CodexAdapter extends NdjsonStreamAdapter {
|
|
|
278
284
|
}
|
|
279
285
|
}
|
|
280
286
|
|
|
287
|
+
/**
|
|
288
|
+
* Map a Codex JSONL event to a `RuntimeStatusEvent` for the dispatcher's
|
|
289
|
+
* thinking UI. Returns `undefined` for events that should not influence
|
|
290
|
+
* status (the dispatcher already synthesizes a generic marker on the first
|
|
291
|
+
* non-assistant block, so we only override when a label is meaningful).
|
|
292
|
+
*/
|
|
293
|
+
function codexStatusEvent(obj: {
|
|
294
|
+
type?: string;
|
|
295
|
+
item?: { type?: string };
|
|
296
|
+
turn?: { status?: string };
|
|
297
|
+
}): import("../types.js").RuntimeStatusEvent | undefined {
|
|
298
|
+
if (obj.type === "thread.started") {
|
|
299
|
+
return { kind: "thinking", phase: "started", label: "Starting session" };
|
|
300
|
+
}
|
|
301
|
+
if (obj.type === "turn.started") {
|
|
302
|
+
return { kind: "thinking", phase: "started", label: "Thinking" };
|
|
303
|
+
}
|
|
304
|
+
if (obj.type === "item.started" && typeof obj.item?.type === "string") {
|
|
305
|
+
const tool = obj.item.type;
|
|
306
|
+
if (
|
|
307
|
+
tool === "command_execution" ||
|
|
308
|
+
tool === "file_change" ||
|
|
309
|
+
tool === "mcp_tool_call" ||
|
|
310
|
+
tool === "web_search"
|
|
311
|
+
) {
|
|
312
|
+
return { kind: "thinking", phase: "updated", label: codexToolLabel(tool) };
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
if (obj.type === "item.completed" && obj.item?.type === "agent_message") {
|
|
316
|
+
return { kind: "thinking", phase: "stopped" };
|
|
317
|
+
}
|
|
318
|
+
if (obj.type === "turn.completed") {
|
|
319
|
+
return { kind: "thinking", phase: "stopped" };
|
|
320
|
+
}
|
|
321
|
+
return undefined;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function codexToolLabel(tool: string): string {
|
|
325
|
+
switch (tool) {
|
|
326
|
+
case "command_execution":
|
|
327
|
+
return "Running command";
|
|
328
|
+
case "file_change":
|
|
329
|
+
return "Editing files";
|
|
330
|
+
case "mcp_tool_call":
|
|
331
|
+
return "Calling tool";
|
|
332
|
+
case "web_search":
|
|
333
|
+
return "Searching web";
|
|
334
|
+
default:
|
|
335
|
+
return tool;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
281
339
|
/**
|
|
282
340
|
* Atomically overwrite `<CODEX_HOME>/AGENTS.md` with `systemContext`. codex
|
|
283
341
|
* reads this file at process start, so the write must complete before spawn.
|
|
@@ -181,32 +181,47 @@ export class HermesAgentAdapter extends AcpRuntimeAdapter {
|
|
|
181
181
|
* assistant text. We surface the common shapes that hermes emits:
|
|
182
182
|
* - `agent_message_chunk` / `user_message_chunk` content blocks
|
|
183
183
|
* - `tool_call` / `tool_call_update`
|
|
184
|
-
* - `agent_thought_chunk`
|
|
184
|
+
* - `agent_thought_chunk` (status-only — see below)
|
|
185
185
|
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
186
|
+
* `agent_thought_chunk` deliberately maps to ONLY a `thinking.updated`
|
|
187
|
+
* status event, NOT a block: the underlying ACP payload has no `subtype`
|
|
188
|
+
* / `session_id` / `model` fields that `normalizeBlockForHub("system")`
|
|
189
|
+
* would render, so emitting a `kind:"system"` block here just produces an
|
|
190
|
+
* empty payload alongside the labeled thinking frame. Anything else is
|
|
191
|
+
* forwarded as `kind: "other"` so subclasses / downstream channels can
|
|
192
|
+
* introspect.
|
|
188
193
|
*/
|
|
189
194
|
protected onUpdate(params: AcpUpdateParams, ctx: AcpUpdateCtx): void {
|
|
190
195
|
const update = params.update ?? {};
|
|
191
196
|
const kind = typeof update.sessionUpdate === "string" ? update.sessionUpdate : "";
|
|
192
197
|
|
|
198
|
+
if (kind === "agent_thought_chunk") {
|
|
199
|
+
ctx.emitStatus({ kind: "thinking", phase: "updated", label: "Thinking" });
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
193
203
|
let blockKind: StreamBlock["kind"] = "other";
|
|
204
|
+
let assistantTextSeen = false;
|
|
194
205
|
|
|
195
206
|
if (kind === "agent_message_chunk") {
|
|
196
207
|
const content = (update as { content?: { type?: string; text?: string } })
|
|
197
208
|
.content;
|
|
198
209
|
if (content && content.type === "text" && typeof content.text === "string") {
|
|
199
210
|
ctx.appendAssistantText(content.text);
|
|
211
|
+
assistantTextSeen = content.text.length > 0;
|
|
200
212
|
}
|
|
201
213
|
blockKind = "assistant_text";
|
|
202
|
-
} else if (kind === "agent_thought_chunk") {
|
|
203
|
-
blockKind = "system";
|
|
204
214
|
} else if (kind === "tool_call" || kind === "tool_call_update") {
|
|
205
215
|
blockKind = "tool_use";
|
|
206
216
|
} else if (kind === "user_message_chunk") {
|
|
207
217
|
blockKind = "other";
|
|
208
218
|
}
|
|
209
219
|
|
|
220
|
+
// Status hint BEFORE the block so the dispatcher's auto-synthesis sees a
|
|
221
|
+
// labeled `thinking.updated`/`stopped` instead of a bare `started`.
|
|
222
|
+
const status = hermesStatusEvent(kind, update, assistantTextSeen);
|
|
223
|
+
if (status) ctx.emitStatus(status);
|
|
224
|
+
|
|
210
225
|
ctx.emitBlock({ raw: params, kind: blockKind, seq: ctx.seq });
|
|
211
226
|
}
|
|
212
227
|
|
|
@@ -237,3 +252,28 @@ export class HermesAgentAdapter extends AcpRuntimeAdapter {
|
|
|
237
252
|
return { outcome: { outcome: "cancelled" } };
|
|
238
253
|
}
|
|
239
254
|
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Map an ACP `session/update` payload to a `RuntimeStatusEvent`. We only
|
|
258
|
+
* return events that add a label or convey a transition the dispatcher
|
|
259
|
+
* cannot infer from block kinds — the auto-synthesis path covers the rest.
|
|
260
|
+
*/
|
|
261
|
+
function hermesStatusEvent(
|
|
262
|
+
kind: string,
|
|
263
|
+
update: { content?: { type?: string; text?: string }; toolCall?: { name?: string } } & Record<
|
|
264
|
+
string,
|
|
265
|
+
unknown
|
|
266
|
+
>,
|
|
267
|
+
assistantTextSeen: boolean,
|
|
268
|
+
): import("../types.js").RuntimeStatusEvent | undefined {
|
|
269
|
+
// `agent_thought_chunk` is handled inline in `onUpdate` (status-only path).
|
|
270
|
+
if (kind === "tool_call" || kind === "tool_call_update") {
|
|
271
|
+
const tool = (update as { toolCall?: { name?: string } }).toolCall;
|
|
272
|
+
const name = typeof tool?.name === "string" && tool.name ? tool.name : "tool";
|
|
273
|
+
return { kind: "thinking", phase: "updated", label: name };
|
|
274
|
+
}
|
|
275
|
+
if (kind === "agent_message_chunk" && assistantTextSeen) {
|
|
276
|
+
return { kind: "thinking", phase: "stopped" };
|
|
277
|
+
}
|
|
278
|
+
return undefined;
|
|
279
|
+
}
|
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
RuntimeProbeResult,
|
|
7
7
|
RuntimeRunOptions,
|
|
8
8
|
RuntimeRunResult,
|
|
9
|
+
RuntimeStatusEvent,
|
|
9
10
|
StreamBlock,
|
|
10
11
|
} from "../types.js";
|
|
11
12
|
|
|
@@ -40,6 +41,13 @@ export interface NdjsonEventCtx {
|
|
|
40
41
|
* Subclasses should use this instead of `state.assistantTextChunks.push(...)`.
|
|
41
42
|
*/
|
|
42
43
|
appendAssistantText: (text: string) => void;
|
|
44
|
+
/**
|
|
45
|
+
* Forward a runtime status event (typing / thinking) to the dispatcher.
|
|
46
|
+
* Adapters should call this when an event reveals the runtime's lifecycle
|
|
47
|
+
* stage before any visible block lands — e.g. Codex `thread.started`,
|
|
48
|
+
* Claude Code `system` init. Errors thrown here are swallowed.
|
|
49
|
+
*/
|
|
50
|
+
emitStatus: (event: RuntimeStatusEvent) => void;
|
|
43
51
|
}
|
|
44
52
|
|
|
45
53
|
const log = consoleLogger;
|
|
@@ -189,6 +197,13 @@ export abstract class NdjsonStreamAdapter implements RuntimeAdapter {
|
|
|
189
197
|
seq,
|
|
190
198
|
emitBlock: (b) => opts.onBlock?.(b),
|
|
191
199
|
appendAssistantText,
|
|
200
|
+
emitStatus: (e) => {
|
|
201
|
+
try {
|
|
202
|
+
opts.onStatus?.(e);
|
|
203
|
+
} catch (err) {
|
|
204
|
+
log.warn(`${this.id} onStatus threw`, { err: String(err) });
|
|
205
|
+
}
|
|
206
|
+
},
|
|
192
207
|
});
|
|
193
208
|
} catch (err) {
|
|
194
209
|
log.warn(`${this.id} event handler threw`, { err: String(err) });
|