@botcord/daemon 0.2.48 → 0.2.50

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.
@@ -57,6 +57,33 @@ describe("composeBotCordUserTurn", () => {
57
57
  expect(out).toContain("mentioned: true");
58
58
  });
59
59
 
60
+ it("renders structured room context outside the human message body", () => {
61
+ const out = composeBotCordUserTurn(
62
+ makeMessage({
63
+ sender: { id: "hu_alice", name: "Alice", kind: "user" },
64
+ text: "@Harry(ag_973dfb9193eb) 今天的AI日报发一下呢",
65
+ conversation: { id: "rm_news", kind: "group", title: "AI News Daily Brief" },
66
+ raw: {
67
+ room_id: "rm_news",
68
+ room_name: "AI News Daily Brief",
69
+ room_member_count: 6,
70
+ room_member_names: ["Alice", "Harry"],
71
+ my_role: "member",
72
+ my_can_send: true,
73
+ room_rule: "Post concise daily summaries.",
74
+ },
75
+ }),
76
+ );
77
+ const roomIdx = out.indexOf("[BotCord Room]");
78
+ const tagIdx = out.indexOf('<human-message sender="Alice" sender_kind="human">');
79
+ const closeIdx = out.indexOf("</human-message>");
80
+ expect(roomIdx).toBeGreaterThan(-1);
81
+ expect(roomIdx).toBeLessThan(tagIdx);
82
+ expect(out).toContain("[Room Rule] Post concise daily summaries.");
83
+ expect(out.slice(tagIdx, closeIdx)).not.toContain("[BotCord Room]");
84
+ expect(out.slice(tagIdx, closeIdx)).toContain("@Harry(ag_973dfb9193eb)");
85
+ });
86
+
60
87
  it("emits the direct-chat hint (not the group hint) for DM conversations", () => {
61
88
  const out = composeBotCordUserTurn(
62
89
  makeMessage({
package/src/daemon.ts CHANGED
@@ -48,10 +48,11 @@ import { PolicyResolver } from "./gateway/policy-resolver.js";
48
48
  import { scanMention } from "./mention-scan.js";
49
49
 
50
50
  /**
51
- * Matches the 10-minute turn timeout the legacy daemon dispatcher used, so
52
- * long-running CLI turns behave the same way under the gateway core.
51
+ * Default hard cap for a single runtime turn. Long-running coding/research
52
+ * tasks routinely exceed 10 minutes, so daemon-hosted agents get a larger
53
+ * window before the dispatcher aborts the runtime.
53
54
  */
54
- const DEFAULT_TURN_TIMEOUT_MS = 10 * 60 * 1000;
55
+ const DEFAULT_TURN_TIMEOUT_MS = 30 * 60 * 1000;
55
56
 
56
57
  /**
57
58
  * Default cadence for writing `gateway.snapshot()` to disk. Override via
@@ -0,0 +1,212 @@
1
+ import { afterAll, describe, expect, it } from "vitest";
2
+ import http, { type ServerResponse } from "node:http";
3
+ import { mkdtempSync, rmSync } from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { DeepseekTuiAdapter } from "../runtimes/deepseek-tui.js";
7
+
8
+ const tmpRoot = mkdtempSync(path.join(os.tmpdir(), "gateway-deepseek-tui-"));
9
+
10
+ afterAll(() => {
11
+ rmSync(tmpRoot, { recursive: true, force: true });
12
+ });
13
+
14
+ function sse(event: string, data: unknown): string {
15
+ return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
16
+ }
17
+
18
+ async function startMockDeepseekServer(opts?: {
19
+ token?: string;
20
+ threadId?: string;
21
+ turnId?: string;
22
+ events?: Array<{ event: string; data: unknown }>;
23
+ }) {
24
+ const token = opts?.token ?? "test-token";
25
+ const threadId = opts?.threadId ?? "thr_test";
26
+ const turnId = opts?.turnId ?? "turn_test";
27
+ const events =
28
+ opts?.events ??
29
+ [
30
+ { event: "turn.started", data: { thread_id: threadId, turn_id: turnId } },
31
+ { event: "tool.started", data: { id: "tool_1", name: "shell", input: { command: "pwd" } } },
32
+ { event: "tool.completed", data: { id: "tool_1", success: true, output: "/tmp" } },
33
+ { event: "message.delta", data: { thread_id: threadId, turn_id: turnId, content: "hello " } },
34
+ { event: "message.delta", data: { thread_id: threadId, turn_id: turnId, content: "deepseek" } },
35
+ { event: "turn.completed", data: { thread_id: threadId, turn_id: turnId, usage: {} } },
36
+ ];
37
+
38
+ const calls: Array<{ method: string; url: string; body?: any; auth?: string }> = [];
39
+ let eventRes: ServerResponse | null = null;
40
+
41
+ const server = http.createServer((req, res) => {
42
+ const chunks: Buffer[] = [];
43
+ req.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
44
+ req.on("end", () => {
45
+ const rawBody = Buffer.concat(chunks).toString("utf8");
46
+ const body = rawBody ? JSON.parse(rawBody) : undefined;
47
+ calls.push({
48
+ method: req.method ?? "",
49
+ url: req.url ?? "",
50
+ body,
51
+ auth: req.headers.authorization,
52
+ });
53
+
54
+ if (req.url === "/health") {
55
+ res.writeHead(200, { "content-type": "application/json" });
56
+ res.end(JSON.stringify({ status: "ok" }));
57
+ return;
58
+ }
59
+ if (req.headers.authorization !== `Bearer ${token}`) {
60
+ res.writeHead(401, { "content-type": "application/json" });
61
+ res.end(JSON.stringify({ error: "unauthorized" }));
62
+ return;
63
+ }
64
+ if (req.method === "POST" && req.url === "/v1/threads") {
65
+ res.writeHead(201, { "content-type": "application/json" });
66
+ res.end(JSON.stringify({ id: threadId }));
67
+ return;
68
+ }
69
+ if (req.method === "PATCH" && req.url === `/v1/threads/${threadId}`) {
70
+ res.writeHead(200, { "content-type": "application/json" });
71
+ res.end(JSON.stringify({ id: threadId }));
72
+ return;
73
+ }
74
+ if (req.method === "GET" && req.url === `/v1/threads/${threadId}/events?since_seq=0`) {
75
+ res.writeHead(200, {
76
+ "content-type": "text/event-stream",
77
+ "cache-control": "no-cache",
78
+ connection: "keep-alive",
79
+ });
80
+ eventRes = res;
81
+ return;
82
+ }
83
+ if (req.method === "POST" && req.url === `/v1/threads/${threadId}/turns`) {
84
+ res.writeHead(201, { "content-type": "application/json" });
85
+ res.end(JSON.stringify({ thread: { id: threadId }, turn: { id: turnId } }));
86
+ setTimeout(() => {
87
+ for (const ev of events) eventRes?.write(sse(ev.event, ev.data));
88
+ eventRes?.end();
89
+ }, 5);
90
+ return;
91
+ }
92
+ res.writeHead(404, { "content-type": "application/json" });
93
+ res.end(JSON.stringify({ error: "not found" }));
94
+ });
95
+ });
96
+
97
+ await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
98
+ const addr = server.address();
99
+ if (!addr || typeof addr !== "object") throw new Error("server did not bind");
100
+ return {
101
+ baseUrl: `http://127.0.0.1:${addr.port}`,
102
+ token,
103
+ threadId,
104
+ calls,
105
+ close: () => new Promise<void>((resolve) => server.close(() => resolve())),
106
+ };
107
+ }
108
+
109
+ function runAdapter(serverUrl: string, authToken: string, sessionId: string | null = null) {
110
+ const adapter = new DeepseekTuiAdapter({ serverUrl, authToken });
111
+ const ctrl = new AbortController();
112
+ const blocks: string[] = [];
113
+ const status: Array<{ phase: string; label?: string }> = [];
114
+ const result = adapter.run({
115
+ text: "hi",
116
+ sessionId,
117
+ accountId: "ag_deepseek",
118
+ cwd: tmpRoot,
119
+ signal: ctrl.signal,
120
+ trustLevel: "owner",
121
+ systemContext: "runtime memory",
122
+ onBlock: (b) => blocks.push(b.kind),
123
+ onStatus: (e) => {
124
+ if (e.kind === "thinking") status.push({ phase: e.phase, label: e.label });
125
+ },
126
+ });
127
+ return { result, blocks, status };
128
+ }
129
+
130
+ describe("DeepseekTuiAdapter", () => {
131
+ it("creates a thread, starts a turn, parses SSE assistant text, and emits tool blocks", async () => {
132
+ const server = await startMockDeepseekServer();
133
+ try {
134
+ const { result, blocks, status } = runAdapter(server.baseUrl, server.token);
135
+ const res = await result;
136
+ expect(res).toEqual({ text: "hello deepseek", newSessionId: server.threadId });
137
+ expect(blocks).toContain("tool_use");
138
+ expect(blocks).toContain("tool_result");
139
+ expect(blocks).toContain("assistant_text");
140
+ expect(status).toContainEqual({ phase: "started", label: "Thinking" });
141
+ expect(status).toContainEqual({ phase: "updated", label: "shell" });
142
+ expect(status.at(-1)).toEqual({ phase: "stopped", label: undefined });
143
+ expect(server.calls.find((c) => c.method === "POST" && c.url === "/v1/threads")?.body).toMatchObject({
144
+ workspace: tmpRoot,
145
+ system_prompt: "runtime memory",
146
+ auto_approve: true,
147
+ });
148
+ } finally {
149
+ await server.close();
150
+ }
151
+ });
152
+
153
+ it("reuses an existing DeepSeek thread id and patches per-turn system context", async () => {
154
+ const server = await startMockDeepseekServer({ threadId: "thr_existing" });
155
+ try {
156
+ const { result } = runAdapter(server.baseUrl, server.token, "thr_existing");
157
+ const res = await result;
158
+ expect(res.newSessionId).toBe("thr_existing");
159
+ expect(server.calls.some((c) => c.method === "POST" && c.url === "/v1/threads")).toBe(false);
160
+ const patch = server.calls.find((c) => c.method === "PATCH");
161
+ expect(patch?.url).toBe("/v1/threads/thr_existing");
162
+ expect(patch?.body).toEqual({ system_prompt: "runtime memory" });
163
+ } finally {
164
+ await server.close();
165
+ }
166
+ });
167
+
168
+ it("clears stale session ids when DeepSeek reports the thread missing", async () => {
169
+ const server = await startMockDeepseekServer({ threadId: "thr_other" });
170
+ try {
171
+ const adapter = new DeepseekTuiAdapter({ serverUrl: server.baseUrl, authToken: server.token });
172
+ const ctrl = new AbortController();
173
+ const res = await adapter.run({
174
+ text: "hi",
175
+ sessionId: "thr_missing",
176
+ accountId: "ag_deepseek",
177
+ cwd: tmpRoot,
178
+ signal: ctrl.signal,
179
+ trustLevel: "owner",
180
+ });
181
+ expect(res.newSessionId).toBe("");
182
+ expect(res.error).toMatch(/HTTP 404/);
183
+ } finally {
184
+ await server.close();
185
+ }
186
+ });
187
+
188
+ it("returns a runtime error when DeepSeek completes the turn as failed", async () => {
189
+ const server = await startMockDeepseekServer({
190
+ events: [
191
+ { event: "turn.started", data: { thread_id: "thr_test", turn_id: "turn_test" } },
192
+ {
193
+ event: "turn.completed",
194
+ data: {
195
+ thread_id: "thr_test",
196
+ turn_id: "turn_test",
197
+ payload: { turn: { status: "failed", error: "missing api key" } },
198
+ },
199
+ },
200
+ ],
201
+ });
202
+ try {
203
+ const { result } = runAdapter(server.baseUrl, server.token);
204
+ const res = await result;
205
+ expect(res.text).toBe("");
206
+ expect(res.newSessionId).toBe("thr_test");
207
+ expect(res.error).toBe("missing api key");
208
+ } finally {
209
+ await server.close();
210
+ }
211
+ });
212
+ });
@@ -27,6 +27,7 @@ function silentLogger(): GatewayLogger {
27
27
 
28
28
  interface FakeChannelOptions {
29
29
  id?: string;
30
+ type?: string;
30
31
  withStream?: boolean;
31
32
  withTyping?: boolean;
32
33
  sendImpl?: (ctx: ChannelSendContext) => Promise<ChannelSendResult> | ChannelSendResult;
@@ -36,7 +37,7 @@ interface FakeChannelOptions {
36
37
 
37
38
  class FakeChannel implements ChannelAdapter {
38
39
  readonly id: string;
39
- readonly type = "fake";
40
+ readonly type: string;
40
41
  readonly sends: ChannelSendContext[] = [];
41
42
  readonly streams: ChannelStreamBlockContext[] = [];
42
43
  readonly typings: ChannelTypingContext[] = [];
@@ -48,6 +49,7 @@ class FakeChannel implements ChannelAdapter {
48
49
 
49
50
  constructor(opts: FakeChannelOptions = {}) {
50
51
  this.id = opts.id ?? "botcord";
52
+ this.type = opts.type ?? "fake";
51
53
  this.sendImpl = opts.sendImpl;
52
54
  this.streamImpl = opts.streamImpl;
53
55
  this.typingImpl = opts.typingImpl;
@@ -659,8 +661,21 @@ describe("Dispatcher", () => {
659
661
  expect(channel.sends.length).toBe(1);
660
662
  });
661
663
 
662
- it("typing: not fired when streamable is false", async () => {
663
- const channel = new FakeChannel();
664
+ it("typing: fires for non-BotCord channels even when streamable is false", async () => {
665
+ const channel = new FakeChannel({ id: "gw_provider", type: "telegram" });
666
+ const { dispatcher } = await scaffold({
667
+ channel,
668
+ runtimeFactory: () => new FakeRuntime({ reply: "ok", newSessionId: "sid" }),
669
+ });
670
+
671
+ await dispatcher.handle(
672
+ makeEnvelope({ channel: "gw_provider", trace: { id: "t1", streamable: false } }),
673
+ );
674
+ expect(channel.typings.length).toBe(1);
675
+ });
676
+
677
+ it("typing: not fired for BotCord rooms when streamable is false", async () => {
678
+ const channel = new FakeChannel({ type: "botcord" });
664
679
  const { dispatcher } = await scaffold({
665
680
  channel,
666
681
  runtimeFactory: () => new FakeRuntime({ reply: "ok", newSessionId: "sid" }),
@@ -672,6 +687,41 @@ describe("Dispatcher", () => {
672
687
  expect(channel.typings.length).toBe(0);
673
688
  });
674
689
 
690
+ it("typing: refreshes while a provider turn is still running", async () => {
691
+ vi.useFakeTimers();
692
+ try {
693
+ const channel = new FakeChannel({ id: "gw_tg", type: "telegram" });
694
+ const runtime = new FakeRuntime({ delayMs: 8500, reply: "ok", newSessionId: "sid" });
695
+ const { dispatcher } = await scaffold({ channel, runtimeFactory: () => runtime });
696
+
697
+ const pending = dispatcher.handle(
698
+ makeEnvelope({
699
+ channel: "gw_tg",
700
+ conversation: { id: "telegram:user:42", kind: "direct" },
701
+ trace: { id: "telegram:42:1", streamable: true },
702
+ }),
703
+ );
704
+
705
+ await vi.advanceTimersByTimeAsync(0);
706
+ expect(channel.typings.length).toBe(1);
707
+
708
+ await vi.advanceTimersByTimeAsync(4000);
709
+ expect(channel.typings.length).toBe(2);
710
+
711
+ await vi.advanceTimersByTimeAsync(4000);
712
+ expect(channel.typings.length).toBe(3);
713
+
714
+ await vi.advanceTimersByTimeAsync(500);
715
+ await pending;
716
+
717
+ await vi.advanceTimersByTimeAsync(4000);
718
+ expect(channel.typings.length).toBe(3);
719
+ expect(channel.sends.length).toBe(1);
720
+ } finally {
721
+ vi.useRealTimers();
722
+ }
723
+ });
724
+
675
725
  it("typing: not fired when channel has no typing capability", async () => {
676
726
  const channel = new FakeChannel({ withTyping: false });
677
727
  const { dispatcher } = await scaffold({
@@ -0,0 +1,174 @@
1
+ import { afterAll, describe, expect, it } from "vitest";
2
+ import { mkdtempSync, rmSync, writeFileSync, chmodSync } from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { KimiAdapter } from "../runtimes/kimi.js";
6
+ import { createRuntime, envVarForRuntime, listRuntimeIds } from "../runtimes/registry.js";
7
+
8
+ const tmpRoot = mkdtempSync(path.join(os.tmpdir(), "gateway-kimi-"));
9
+
10
+ function makeScript(name: string, body: string): string {
11
+ const p = path.join(tmpRoot, name);
12
+ writeFileSync(p, `#!/usr/bin/env node\n${body}\n`, { mode: 0o755 });
13
+ chmodSync(p, 0o755);
14
+ return p;
15
+ }
16
+
17
+ afterAll(() => {
18
+ rmSync(tmpRoot, { recursive: true, force: true });
19
+ });
20
+
21
+ function runAdapter(script: string, sessionId: string | null = null) {
22
+ const adapter = new KimiAdapter({ binary: script });
23
+ const ctrl = new AbortController();
24
+ return adapter.run({
25
+ text: "hi",
26
+ sessionId,
27
+ accountId: "ag_test",
28
+ cwd: tmpRoot,
29
+ signal: ctrl.signal,
30
+ trustLevel: "owner",
31
+ });
32
+ }
33
+
34
+ describe("KimiAdapter", () => {
35
+ it("is registered as a runnable runtime", () => {
36
+ expect(listRuntimeIds()).toContain("kimi-cli");
37
+ expect(envVarForRuntime("kimi-cli")).toBe("BOTCORD_KIMI_CLI_BIN");
38
+ expect(createRuntime("kimi-cli")).toBeInstanceOf(KimiAdapter);
39
+ });
40
+
41
+ it("parses final assistant text and persists the generated --session id", async () => {
42
+ const script = makeScript(
43
+ "happy.js",
44
+ `
45
+ process.stdout.write(JSON.stringify({role:"assistant", content:"hello from kimi"}) + "\\n");
46
+ `,
47
+ );
48
+ const res = await runAdapter(script);
49
+ expect(res.newSessionId).toMatch(/^[0-9a-f-]{36}$/);
50
+ expect(res.text).toBe("hello from kimi");
51
+ expect(res.error).toBeUndefined();
52
+ });
53
+
54
+ it("passes an existing session id through --session", async () => {
55
+ const script = makeScript(
56
+ "resume-argv.js",
57
+ `
58
+ const argv = process.argv.slice(2);
59
+ process.stdout.write(JSON.stringify({role:"assistant", content:JSON.stringify(argv)}) + "\\n");
60
+ `,
61
+ );
62
+ const res = await runAdapter(script, "sid-123");
63
+ const argv = JSON.parse(res.text) as string[];
64
+ const idx = argv.indexOf("--session");
65
+ expect(idx).toBeGreaterThanOrEqual(0);
66
+ expect(argv[idx + 1]).toBe("sid-123");
67
+ expect(argv).toContain("--print");
68
+ expect(argv).toContain("stream-json");
69
+ expect(argv).toContain("--afk");
70
+ });
71
+
72
+ it("rejects session ids that could be parsed as flags", async () => {
73
+ const script = makeScript(
74
+ "should-not-spawn.js",
75
+ `
76
+ process.stdout.write(JSON.stringify({role:"assistant", content:"spawned"}) + "\\n");
77
+ `,
78
+ );
79
+ const res = await runAdapter(script, "--bad");
80
+ expect(res.newSessionId).toBe("");
81
+ expect(res.text).toBe("");
82
+ expect(res.error).toMatch(/invalid sessionId/);
83
+ });
84
+
85
+ it("prefixes systemContext as a system-reminder in the prompt", async () => {
86
+ const script = makeScript(
87
+ "echo-prompt.js",
88
+ `
89
+ const argv = process.argv.slice(2);
90
+ const prompt = argv[argv.indexOf("--prompt") + 1];
91
+ process.stdout.write(JSON.stringify({role:"assistant", content:prompt}) + "\\n");
92
+ `,
93
+ );
94
+ const adapter = new KimiAdapter({ binary: script });
95
+ const ctrl = new AbortController();
96
+ const res = await adapter.run({
97
+ text: "do the thing",
98
+ sessionId: null,
99
+ accountId: "ag_test",
100
+ cwd: tmpRoot,
101
+ signal: ctrl.signal,
102
+ trustLevel: "owner",
103
+ systemContext: "MEMORY: remember X",
104
+ });
105
+ expect(res.text).toContain("<system-reminder>");
106
+ expect(res.text).toContain("MEMORY: remember X");
107
+ expect(res.text).toContain("do the thing");
108
+ });
109
+
110
+ it("recognizes tool_use and tool_result blocks", async () => {
111
+ const script = makeScript(
112
+ "tools.js",
113
+ `
114
+ const lines = [
115
+ {role:"assistant", content:null, tool_calls:[{id:"tc1", function:{name:"Bash", arguments:"{}"}}]},
116
+ {role:"tool", tool_call_id:"tc1", content:"ok"},
117
+ {role:"assistant", content:[{type:"text", text:"done"}]},
118
+ ];
119
+ for (const l of lines) process.stdout.write(JSON.stringify(l) + "\\n");
120
+ `,
121
+ );
122
+ const adapter = new KimiAdapter({ binary: script });
123
+ const ctrl = new AbortController();
124
+ const seen: string[] = [];
125
+ const res = await adapter.run({
126
+ text: "x",
127
+ sessionId: null,
128
+ accountId: "ag_test",
129
+ cwd: tmpRoot,
130
+ signal: ctrl.signal,
131
+ trustLevel: "owner",
132
+ onBlock: (b) => seen.push(b.kind),
133
+ });
134
+ expect(res.text).toBe("done");
135
+ expect(seen).toContain("tool_use");
136
+ expect(seen).toContain("tool_result");
137
+ expect(seen).toContain("assistant_text");
138
+ });
139
+
140
+ it("emits thinking status for think, tool call, tool result, and final text", async () => {
141
+ const script = makeScript(
142
+ "thinkflow.js",
143
+ `
144
+ const lines = [
145
+ {role:"assistant", content:[{type:"think", think:"working"}]},
146
+ {role:"assistant", content:null, tool_calls:[{id:"tc1", function:{name:"ReadFile", arguments:"{}"}}]},
147
+ {role:"tool", tool_call_id:"tc1", content:"ok"},
148
+ {role:"assistant", content:"done"},
149
+ ];
150
+ for (const l of lines) process.stdout.write(JSON.stringify(l) + "\\n");
151
+ `,
152
+ );
153
+ const adapter = new KimiAdapter({ binary: script });
154
+ const ctrl = new AbortController();
155
+ const status: Array<{ phase: string; label?: string }> = [];
156
+ await adapter.run({
157
+ text: "x",
158
+ sessionId: null,
159
+ accountId: "ag_test",
160
+ cwd: tmpRoot,
161
+ signal: ctrl.signal,
162
+ trustLevel: "owner",
163
+ onStatus: (e) => {
164
+ if (e.kind === "thinking") status.push({ phase: e.phase, label: e.label });
165
+ },
166
+ });
167
+ expect(status).toEqual([
168
+ { phase: "started", label: "Thinking" },
169
+ { phase: "updated", label: "ReadFile" },
170
+ { phase: "updated", label: "Tool result" },
171
+ { phase: "stopped", label: undefined },
172
+ ]);
173
+ });
174
+ });
@@ -28,7 +28,7 @@ import type {
28
28
  UserTurnBuilder,
29
29
  } from "./types.js";
30
30
 
31
- const DEFAULT_TURN_TIMEOUT_MS = 10 * 60 * 1000;
31
+ const DEFAULT_TURN_TIMEOUT_MS = 30 * 60 * 1000;
32
32
 
33
33
  /**
34
34
  * Owner-chat room prefix. Reply-text gating: only rooms with this prefix get
@@ -55,6 +55,14 @@ const MAX_BATCH_BUFFER_CHARS = 16000;
55
55
  */
56
56
  const TYPING_DEBOUNCE_MS = 2000;
57
57
 
58
+ /**
59
+ * Most provider typing APIs are short-lived one-shots. Telegram's
60
+ * `sendChatAction(typing)`, for example, must be refreshed while the runtime
61
+ * is still working or the visible typing indicator disappears before the
62
+ * reply lands.
63
+ */
64
+ const TYPING_REFRESH_MS = 4000;
65
+
58
66
  /** LRU cap on the typing-recency map so long-running daemons don't grow unbounded. */
59
67
  const TYPING_RECENCY_CAP = 1024;
60
68
 
@@ -804,7 +812,9 @@ export class Dispatcher {
804
812
  const streamable = msg.trace?.streamable === true;
805
813
  const traceId = msg.trace?.id;
806
814
  const canType =
807
- streamable && typeof traceId === "string" && typeof channel.typing === "function";
815
+ typeof traceId === "string" &&
816
+ typeof channel.typing === "function" &&
817
+ (streamable || !isBotCordChannel(channel));
808
818
  const canStream =
809
819
  streamable && typeof traceId === "string" && typeof channel.streamBlock === "function";
810
820
  const recordBlock = (block: StreamBlock): void => {
@@ -824,7 +834,8 @@ export class Dispatcher {
824
834
  // arrival), and `thinking` is auto-synthesized on the first non-assistant
825
835
  // block so adapters that emit nothing-but-blocks still drive the
826
836
  // "Thinking..." UI.
827
- let typingFired = false;
837
+ let typingLoopStarted = false;
838
+ let typingRefreshTimer: NodeJS.Timeout | null = null;
828
839
  let thinkingActive = false;
829
840
  /**
830
841
  * Sticky: once we've forwarded any assistant_text to the wire, we stop
@@ -887,9 +898,8 @@ export class Dispatcher {
887
898
  forwardBlockToChannel(synth);
888
899
  };
889
900
 
890
- const fireTypingIfNeeded = (): void => {
891
- if (!canType || typingFired) return;
892
- typingFired = true;
901
+ const sendTypingPing = (): void => {
902
+ if (!canType) return;
893
903
  const key = `${msg.accountId}:${msg.conversation.id}`;
894
904
  const now = Date.now();
895
905
  const last = this.recentTypingPings.get(key);
@@ -934,7 +944,21 @@ export class Dispatcher {
934
944
  }
935
945
  };
936
946
 
937
- const onStatus = canStream
947
+ const fireTypingIfNeeded = (): void => {
948
+ if (!canType || typingLoopStarted) return;
949
+ typingLoopStarted = true;
950
+ sendTypingPing();
951
+ typingRefreshTimer = setInterval(sendTypingPing, TYPING_REFRESH_MS);
952
+ if (typeof typingRefreshTimer.unref === "function") typingRefreshTimer.unref();
953
+ };
954
+
955
+ const stopTypingRefresh = (): void => {
956
+ if (!typingRefreshTimer) return;
957
+ clearInterval(typingRefreshTimer);
958
+ typingRefreshTimer = null;
959
+ };
960
+
961
+ const onStatus = canType || canStream
938
962
  ? (event: RuntimeStatusEvent) => {
939
963
  // Drop runtime callbacks after this turn's controller aborts —
940
964
  // NDJSON/ACP adapters keep parsing stdout until the child exits
@@ -1342,6 +1366,7 @@ export class Dispatcher {
1342
1366
  blocks: slot.blocks,
1343
1367
  });
1344
1368
  } finally {
1369
+ stopTypingRefresh();
1345
1370
  // Emit a final thinking.stopped on terminal paths so the frontend
1346
1371
  // never sticks at "Thinking..." when no assistant_text ever landed
1347
1372
  // (timeout, error, gated reply). Skipped on cancel-previous: the
@@ -39,6 +39,12 @@ export {
39
39
  resolveClaudeCommand,
40
40
  } from "./runtimes/claude-code.js";
41
41
  export { CodexAdapter, probeCodex, resolveCodexCommand } from "./runtimes/codex.js";
42
+ export {
43
+ DeepseekTuiAdapter,
44
+ probeDeepseekTui,
45
+ resolveDeepseekCommand,
46
+ } from "./runtimes/deepseek-tui.js";
47
+ export { KimiAdapter, probeKimi, resolveKimiCommand } from "./runtimes/kimi.js";
42
48
  export { GeminiAdapter, probeGemini, resolveGeminiCommand } from "./runtimes/gemini.js";
43
49
  export {
44
50
  NdjsonStreamAdapter,