@botcord/daemon 0.2.13 → 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.
@@ -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
- const onBlock = canStream
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
- recordBlock(block);
557
- // Fire-and-forget: stream errors must not break the turn.
558
- channel
559
- .streamBlock({
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
- .catch((err) => {
567
- this.log.warn("dispatcher: streamBlock failed", {
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
- * Anything else is forwarded as `kind: "other"` so subclasses /
77
- * downstream channels can introspect.
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
- * Anything else is forwarded as `kind: "other"` so subclasses /
159
- * downstream channels can introspect.
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) {