@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.
Files changed (34) hide show
  1. package/dist/agent-workspace.js +47 -1
  2. package/dist/gateway/channels/botcord.js +39 -0
  3. package/dist/gateway/dispatcher.d.ts +6 -0
  4. package/dist/gateway/dispatcher.js +207 -9
  5. package/dist/gateway/runtimes/acp-stream.d.ts +7 -1
  6. package/dist/gateway/runtimes/acp-stream.js +19 -0
  7. package/dist/gateway/runtimes/claude-code.js +34 -0
  8. package/dist/gateway/runtimes/codex.js +50 -0
  9. package/dist/gateway/runtimes/hermes-agent.d.ts +8 -3
  10. package/dist/gateway/runtimes/hermes-agent.js +36 -6
  11. package/dist/gateway/runtimes/ndjson-stream.d.ts +8 -1
  12. package/dist/gateway/runtimes/ndjson-stream.js +8 -0
  13. package/dist/gateway/types.d.ts +54 -2
  14. package/dist/index.js +72 -5
  15. package/dist/provision.js +63 -1
  16. package/package.json +1 -1
  17. package/src/__tests__/agent-workspace.test.ts +25 -0
  18. package/src/__tests__/provision.test.ts +68 -1
  19. package/src/agent-workspace.ts +47 -0
  20. package/src/gateway/__tests__/botcord-channel.test.ts +97 -0
  21. package/src/gateway/__tests__/claude-code-adapter.test.ts +35 -0
  22. package/src/gateway/__tests__/codex-adapter.test.ts +44 -0
  23. package/src/gateway/__tests__/dispatcher.test.ts +552 -1
  24. package/src/gateway/__tests__/hermes-agent-adapter.test.ts +39 -0
  25. package/src/gateway/channels/botcord.ts +38 -0
  26. package/src/gateway/dispatcher.ts +217 -15
  27. package/src/gateway/runtimes/acp-stream.ts +24 -0
  28. package/src/gateway/runtimes/claude-code.ts +41 -1
  29. package/src/gateway/runtimes/codex.ts +58 -0
  30. package/src/gateway/runtimes/hermes-agent.ts +45 -5
  31. package/src/gateway/runtimes/ndjson-stream.ts +15 -0
  32. package/src/gateway/types.ts +55 -2
  33. package/src/index.ts +88 -5
  34. 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
- const onBlock = canStream
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
- recordBlock(block);
763
- // Fire-and-forget: stream errors must not break the turn.
764
- channel
765
- .streamBlock!({
766
- traceId: traceId!,
767
- accountId: msg.accountId,
768
- conversationId: msg.conversation.id,
769
- block,
770
- log: this.log,
771
- })
772
- .catch((err) => {
773
- this.log.warn("dispatcher: streamBlock failed", {
774
- traceId,
775
- error: err instanceof Error ? err.message : String(err),
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
- * Anything else is forwarded as `kind: "other"` so subclasses /
187
- * downstream channels can introspect.
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) });