@botcord/daemon 0.2.83 → 0.2.85

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.
@@ -9,7 +9,7 @@
9
9
  *
10
10
  * See ``docs/cloud-agent-technical-design.md`` §4 + §6.
11
11
  */
12
- import { shouldWake } from "@botcord/protocol-core";
12
+ import { CONTROL_FRAME_TYPES, shouldWake } from "@botcord/protocol-core";
13
13
  import { Gateway, resolveTranscriptEnabled, } from "./gateway/index.js";
14
14
  import { ActivityTracker } from "./activity-tracker.js";
15
15
  import { SESSIONS_PATH, SNAPSHOT_PATH } from "./config.js";
@@ -201,10 +201,34 @@ export async function startCloudDaemon(opts) {
201
201
  if (!opts.disableControlChannel) {
202
202
  const auth = asUserAuthManager(new CloudAuthManager(cloudCfg));
203
203
  const provisionerFactory = opts.provisionerFactory ?? createProvisioner;
204
+ // Forward-declare controlChannel-bound emitter: the provisioner is
205
+ // built before the channel exists, so the closure captures the slot
206
+ // and reads it back lazily once cloud-daemon assigns the instance.
207
+ const cloudGatewayTypingEmitter = (event) => {
208
+ const ch = controlChannel;
209
+ if (!ch)
210
+ return;
211
+ ch.send({
212
+ id: `cgrs_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
213
+ type: CONTROL_FRAME_TYPES.CLOUD_GATEWAY_RUNTIME_STATUS,
214
+ params: {
215
+ eventId: event.eventId,
216
+ turnId: event.turnId,
217
+ gatewayId: event.gatewayId,
218
+ agentId: event.agentId,
219
+ conversationId: event.conversationId,
220
+ kind: "typing",
221
+ phase: event.phase,
222
+ traceId: event.traceId ?? null,
223
+ },
224
+ ts: Date.now(),
225
+ });
226
+ };
204
227
  const provisioner = provisionerFactory({
205
228
  gateway,
206
229
  policyResolver,
207
230
  onAgentInstalled,
231
+ cloudGatewayTypingEmitter,
208
232
  });
209
233
  const ControlChannelCtor = opts.controlChannelFactory ?? ControlChannel;
210
234
  controlChannel = new ControlChannelCtor({
@@ -1,5 +1,22 @@
1
1
  import { type GatewayInboundFrame } from "@botcord/protocol-core";
2
2
  import type { Gateway, GatewayLogger } from "./gateway/index.js";
3
+ /**
4
+ * Fire-and-forget hook invoked when the dispatcher signals "typing" while
5
+ * a cloud-gateway runtime turn is in flight. The cloud daemon wires this
6
+ * to a control-plane `cloud_gateway_runtime_status` push so the Hub can
7
+ * relay it to the ingress runtime WS and the third-party provider can
8
+ * render a typing indicator.
9
+ */
10
+ export interface CloudGatewayTypingEvent {
11
+ eventId: string;
12
+ turnId: string;
13
+ gatewayId: string;
14
+ agentId: string;
15
+ conversationId: string;
16
+ phase: "started" | "stopped";
17
+ traceId?: string | null;
18
+ }
19
+ export type CloudGatewayTypingEmitter = (event: CloudGatewayTypingEvent) => void;
3
20
  export interface CloudGatewayRuntimeResult {
4
21
  accepted: boolean;
5
22
  eventId: string;
@@ -26,4 +43,4 @@ export interface CloudGatewayRuntimeResult {
26
43
  * gateway_outbound_complete for gateway-ingress, which then calls the real
27
44
  * provider API.
28
45
  */
29
- export declare function handleCloudGatewayRuntimeInbound(gateway: Gateway, frame: GatewayInboundFrame, log?: GatewayLogger): Promise<CloudGatewayRuntimeResult>;
46
+ export declare function handleCloudGatewayRuntimeInbound(gateway: Gateway, frame: GatewayInboundFrame, log?: GatewayLogger, onTyping?: CloudGatewayTypingEmitter): Promise<CloudGatewayRuntimeResult>;
@@ -9,7 +9,7 @@ import { RUNTIME_FRAME_TYPES, } from "@botcord/protocol-core";
9
9
  * gateway_outbound_complete for gateway-ingress, which then calls the real
10
10
  * provider API.
11
11
  */
12
- export async function handleCloudGatewayRuntimeInbound(gateway, frame, log) {
12
+ export async function handleCloudGatewayRuntimeInbound(gateway, frame, log, onTyping) {
13
13
  if (frame.type !== RUNTIME_FRAME_TYPES.GATEWAY_INBOUND) {
14
14
  return rejected(frame, "bad_frame_type", `unsupported frame type "${frame.type}"`);
15
15
  }
@@ -25,6 +25,7 @@ export async function handleCloudGatewayRuntimeInbound(gateway, frame, log) {
25
25
  let accepted = false;
26
26
  let outboundText = null;
27
27
  let providerMessageId;
28
+ const turnId = `turn_${frame.event_id}`;
28
29
  const channel = createRuntimeRelayChannel({
29
30
  id: frame.gateway_id,
30
31
  provider: frame.provider,
@@ -34,6 +35,29 @@ export async function handleCloudGatewayRuntimeInbound(gateway, frame, log) {
34
35
  providerMessageId = ctx.message.traceId ?? null;
35
36
  return { providerMessageId };
36
37
  },
38
+ onTyping: onTyping
39
+ ? (ctx) => {
40
+ try {
41
+ onTyping({
42
+ eventId: frame.event_id,
43
+ turnId,
44
+ gatewayId: frame.gateway_id,
45
+ agentId: frame.agent_id,
46
+ conversationId: frame.message.conversation.id,
47
+ phase: "started",
48
+ traceId: ctx.traceId ?? null,
49
+ });
50
+ }
51
+ catch (err) {
52
+ log?.warn?.("cloud gateway typing emit failed", {
53
+ eventId: frame.event_id,
54
+ gatewayId: frame.gateway_id,
55
+ agentId: frame.agent_id,
56
+ error: err instanceof Error ? err.message : String(err),
57
+ });
58
+ }
59
+ }
60
+ : undefined,
37
61
  });
38
62
  const message = {
39
63
  ...frame.message,
@@ -67,7 +91,7 @@ export async function handleCloudGatewayRuntimeInbound(gateway, frame, log) {
67
91
  gatewayId: frame.gateway_id,
68
92
  agentId: frame.agent_id,
69
93
  conversationId: frame.message.conversation.id,
70
- turnId: `turn_${frame.event_id}`,
94
+ turnId,
71
95
  ...(outboundText !== null
72
96
  ? {
73
97
  outbound: {
@@ -83,7 +107,7 @@ export async function handleCloudGatewayRuntimeInbound(gateway, frame, log) {
83
107
  }
84
108
  function createRuntimeRelayChannel(opts) {
85
109
  let lastSendAt;
86
- return {
110
+ const adapter = {
87
111
  id: opts.id,
88
112
  type: opts.provider,
89
113
  async start() {
@@ -108,6 +132,12 @@ function createRuntimeRelayChannel(opts) {
108
132
  };
109
133
  },
110
134
  };
135
+ if (opts.onTyping) {
136
+ adapter.typing = async (ctx) => {
137
+ opts.onTyping(ctx);
138
+ };
139
+ }
140
+ return adapter;
111
141
  }
112
142
  function rejected(frame, code, message) {
113
143
  return {
@@ -4,6 +4,7 @@ import type { PolicyResolverLike } from "./gateway/policy-resolver.js";
4
4
  import type { GatewayRuntimeSnapshot } from "./gateway/index.js";
5
5
  import { type DaemonConfig } from "./config.js";
6
6
  import type { LoginSessionStore } from "./gateway/channels/login-session.js";
7
+ import { type CloudGatewayTypingEmitter } from "./cloud-gateway-runtime.js";
7
8
  /**
8
9
  * Information passed to {@link OnAgentInstalledHook} after a successful
9
10
  * provision. Mirrors the credential fields the daemon's per-agent caches
@@ -58,6 +59,13 @@ export interface ProvisionerOptions {
58
59
  * spin up a default in-memory store.
59
60
  */
60
61
  loginSessions?: LoginSessionStore;
62
+ /**
63
+ * Optional callback invoked when an in-flight `cloud_gateway_runtime_inbound`
64
+ * fires a typing event. The cloud daemon wires this to its control-plane
65
+ * connection so the Hub can relay the hint to the live ingress runtime WS.
66
+ * Absent for local daemons (no ingress path).
67
+ */
68
+ cloudGatewayTypingEmitter?: CloudGatewayTypingEmitter;
61
69
  }
62
70
  /** The value a frame handler returns (minus the `id` which the channel fills in). */
63
71
  type AckBody = Omit<ControlAck, "id">;
package/dist/provision.js CHANGED
@@ -20,7 +20,7 @@ import { discoverAgentCredentials } from "./agent-discovery.js";
20
20
  import { resolveMemoryDir } from "./working-memory.js";
21
21
  import { discoverRuntimeModelCatalog } from "./runtime-models.js";
22
22
  import { buildRuntimeSelectionExtraArgs, mergeRuntimeExtraArgs, } from "./runtime-route-options.js";
23
- import { handleCloudGatewayRuntimeInbound } from "./cloud-gateway-runtime.js";
23
+ import { handleCloudGatewayRuntimeInbound, } from "./cloud-gateway-runtime.js";
24
24
  /**
25
25
  * Build a dispatcher function that routes a `ControlFrame` to the right
26
26
  * handler. Returned function signature matches
@@ -31,6 +31,7 @@ export function createProvisioner(opts) {
31
31
  const register = opts.register ?? BotCordClient.register;
32
32
  const policyResolver = opts.policyResolver;
33
33
  const onAgentInstalled = opts.onAgentInstalled;
34
+ const cloudGatewayTypingEmitter = opts.cloudGatewayTypingEmitter;
34
35
  const gatewayControl = createGatewayControl({
35
36
  gateway,
36
37
  ...(opts.loginSessions ? { loginSessions: opts.loginSessions } : {}),
@@ -286,7 +287,7 @@ export function createProvisioner(opts) {
286
287
  },
287
288
  };
288
289
  }
289
- const result = await handleCloudGatewayRuntimeInbound(gateway, runtimeFrame);
290
+ const result = await handleCloudGatewayRuntimeInbound(gateway, runtimeFrame, undefined, cloudGatewayTypingEmitter);
290
291
  return result.accepted
291
292
  ? { ok: true, result }
292
293
  : {
@@ -4,7 +4,7 @@ import { homedir } from "node:os";
4
4
  import path from "node:path";
5
5
  const MODEL_LIST_TIMEOUT_MS = 5000;
6
6
  const MODEL_LIST_MAX_BUFFER = 16 * 1024 * 1024;
7
- const RUNTIME_CATALOG_CACHE_VERSION = 1;
7
+ const RUNTIME_CATALOG_CACHE_VERSION = 2;
8
8
  const RUNTIME_CATALOG_CACHE_FRESH_MS = 10 * 60 * 1000;
9
9
  const DEFAULT_RUNTIME_CATALOG_CACHE_DIR = path.join(homedir(), ".botcord", "daemon", "runtime-catalog-cache");
10
10
  const CLAUDE_ALIAS_MODELS = [
@@ -102,7 +102,7 @@ function runtimeCatalogStrategy(entry) {
102
102
  discoverFresh: () => discoverDeepseekCatalog(entry.result.path),
103
103
  fallback: () => ({
104
104
  models: DEEPSEEK_FALLBACK_MODELS.slice(),
105
- parameters: discoverDeepseekParameters(),
105
+ parameters: discoverDeepseekParameters(entry.result.path),
106
106
  }),
107
107
  };
108
108
  case "kimi-cli":
@@ -392,7 +392,7 @@ function discoverCodexParameters(rawCatalog) {
392
392
  function discoverDeepseekCatalog(command) {
393
393
  return {
394
394
  models: discoverDeepseekModels(command),
395
- parameters: discoverDeepseekParameters(),
395
+ parameters: discoverDeepseekParameters(command),
396
396
  };
397
397
  }
398
398
  export function discoverDeepseekModels(command) {
@@ -416,8 +416,9 @@ export function parseDeepseekModelList(raw) {
416
416
  }
417
417
  return out.length ? out : undefined;
418
418
  }
419
- function discoverDeepseekParameters() {
419
+ function discoverDeepseekParameters(command) {
420
420
  const config = readConfigScalars(path.join(homedir(), ".deepseek", "config.toml"));
421
+ const reasoningEffortValues = discoverDeepseekReasoningEffortValues(command);
421
422
  return [
422
423
  compactParameter({
423
424
  id: "model",
@@ -439,8 +440,9 @@ function discoverDeepseekParameters() {
439
440
  compactParameter({
440
441
  id: "reasoning_effort",
441
442
  displayName: "Reasoning effort",
442
- type: "string",
443
- flag: "reasoning_effort",
443
+ type: reasoningEffortValues.length > 0 ? "enum" : "string",
444
+ flag: "--reasoning-effort",
445
+ values: reasoningEffortValues.length > 0 ? reasoningEffortValues : undefined,
444
446
  defaultValue: config.reasoning_effort,
445
447
  source: config.reasoning_effort ? "config" : "cli",
446
448
  }),
@@ -462,6 +464,46 @@ function discoverDeepseekParameters() {
462
464
  }),
463
465
  ];
464
466
  }
467
+ function discoverDeepseekReasoningEffortValues(command) {
468
+ const candidates = deepseekRuntimeTemplateCandidates(command);
469
+ const values = new Set();
470
+ for (const candidate of candidates) {
471
+ try {
472
+ const raw = readFileSync(candidate)
473
+ .toString("latin1")
474
+ .replace(/[^\x20-\x7E]+/g, "\n");
475
+ const templateRe = /Thinking mode \(DeepSeek V4 reasoning effort\):[\s\S]{0,256}?#\s*((?:"[^"]+"\s*(?:\|\s*)?)+)/g;
476
+ for (const match of raw.matchAll(templateRe)) {
477
+ const line = match[1] ?? "";
478
+ for (const valueMatch of line.matchAll(/"([^"]+)"/g)) {
479
+ const value = valueMatch[1]?.trim();
480
+ if (value && /^[A-Za-z0-9_.-]+$/.test(value))
481
+ values.add(value);
482
+ }
483
+ }
484
+ }
485
+ catch {
486
+ // Try the next candidate; runtime discovery should stay best-effort.
487
+ }
488
+ }
489
+ return Array.from(values);
490
+ }
491
+ function deepseekRuntimeTemplateCandidates(command) {
492
+ if (!command)
493
+ return [];
494
+ const candidates = new Set();
495
+ if (existsSync(command))
496
+ candidates.add(command);
497
+ const dir = path.dirname(command);
498
+ for (const candidate of [
499
+ path.join(dir, "deepseek-tui"),
500
+ path.join(dir, "downloads", "deepseek-tui"),
501
+ ]) {
502
+ if (existsSync(candidate))
503
+ candidates.add(candidate);
504
+ }
505
+ return Array.from(candidates);
506
+ }
465
507
  function discoverKimiCatalog() {
466
508
  const configPath = path.join(homedir(), ".kimi", "config.toml");
467
509
  if (!existsSync(configPath))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botcord/daemon",
3
- "version": "0.2.83",
3
+ "version": "0.2.85",
4
4
  "description": "BotCord local daemon — bridges Hub inbox push to local Claude Code / Codex / Gemini CLIs",
5
5
  "type": "module",
6
6
  "bin": {
@@ -28,7 +28,7 @@
28
28
  },
29
29
  "dependencies": {
30
30
  "@botcord/cli": "^0.1.7",
31
- "@botcord/protocol-core": "^0.2.11",
31
+ "@botcord/protocol-core": "^0.2.12",
32
32
  "@larksuiteoapi/node-sdk": "^1.63.1",
33
33
  "ws": "^8.20.1"
34
34
  },
@@ -4,7 +4,10 @@ import path from "node:path";
4
4
  import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
5
  import { RUNTIME_FRAME_TYPES, type GatewayInboundFrame } from "@botcord/protocol-core";
6
6
 
7
- import { handleCloudGatewayRuntimeInbound } from "../cloud-gateway-runtime.js";
7
+ import {
8
+ handleCloudGatewayRuntimeInbound,
9
+ type CloudGatewayTypingEvent,
10
+ } from "../cloud-gateway-runtime.js";
8
11
  import { Gateway, type ChannelAdapter } from "../gateway/index.js";
9
12
 
10
13
  describe("cloud gateway runtime inbound", () => {
@@ -66,6 +69,64 @@ describe("cloud gateway runtime inbound", () => {
66
69
  expect(result.outbound?.finalText).toBe("hello from runtime");
67
70
  });
68
71
 
72
+ it("invokes the typing emitter when the dispatcher fires typing.started", async () => {
73
+ const typingEvents: CloudGatewayTypingEvent[] = [];
74
+ const gateway = new Gateway({
75
+ config: {
76
+ channels: [],
77
+ defaultRoute: { runtime: "fake", cwd: tmpDir },
78
+ },
79
+ sessionStorePath: path.join(tmpDir, "sessions.json"),
80
+ createChannel: (cfg) => stubChannel(cfg.id, cfg.type, cfg.accountId),
81
+ createRuntime: () => ({
82
+ id: "fake",
83
+ async run() {
84
+ return { text: "ok", newSessionId: "sess_typing" };
85
+ },
86
+ }),
87
+ transcriptEnabled: false,
88
+ });
89
+ await gateway.start();
90
+
91
+ const frame: GatewayInboundFrame = {
92
+ type: RUNTIME_FRAME_TYPES.GATEWAY_INBOUND,
93
+ event_id: "evt_typing",
94
+ gateway_id: "gw_wc_1",
95
+ agent_id: "ag_typing",
96
+ provider: "wechat",
97
+ message: {
98
+ id: "wechat:alice:t1",
99
+ channel: "gw_wc_1",
100
+ accountId: "ag_typing",
101
+ conversation: { id: "wechat:user:alice", kind: "direct" },
102
+ sender: { id: "wechat:user:alice", kind: "user" },
103
+ text: "ping",
104
+ replyTo: null,
105
+ mentioned: true,
106
+ receivedAt: Date.now(),
107
+ trace: { id: "wechat:alice:t1", streamable: false },
108
+ },
109
+ };
110
+
111
+ const result = await handleCloudGatewayRuntimeInbound(
112
+ gateway,
113
+ frame,
114
+ undefined,
115
+ (event) => typingEvents.push(event),
116
+ );
117
+ await gateway.stop("test");
118
+
119
+ expect(result.accepted).toBe(true);
120
+ expect(typingEvents.length).toBeGreaterThanOrEqual(1);
121
+ const first = typingEvents[0]!;
122
+ expect(first.eventId).toBe("evt_typing");
123
+ expect(first.gatewayId).toBe("gw_wc_1");
124
+ expect(first.agentId).toBe("ag_typing");
125
+ expect(first.conversationId).toBe("wechat:user:alice");
126
+ expect(first.phase).toBe("started");
127
+ expect(first.traceId).toBe("wechat:alice:t1");
128
+ });
129
+
69
130
  it("rejects frames outside the token scope", async () => {
70
131
  const gateway = new Gateway({
71
132
  config: {
@@ -199,6 +199,56 @@ describe("runtime model discovery parsers", () => {
199
199
  ]);
200
200
  });
201
201
 
202
+ it("reads DeepSeek reasoning effort choices from the installed runtime template", () => {
203
+ const tmp = mkdtempSync(path.join(tmpdir(), "daemon-deepseek-catalog-"));
204
+ const prevHome = process.env.HOME;
205
+ const prevCacheDir = process.env.BOTCORD_RUNTIME_CATALOG_CACHE_DIR;
206
+ try {
207
+ const home = path.join(tmp, "home");
208
+ mkdirSync(path.join(home, ".deepseek"), { recursive: true });
209
+ process.env.HOME = home;
210
+ process.env.BOTCORD_RUNTIME_CATALOG_CACHE_DIR = path.join(tmp, "catalog-cache");
211
+ writeFileSync(
212
+ path.join(home, ".deepseek", "config.toml"),
213
+ 'default_text_model = "deepseek-v4-pro"\nreasoning_effort = "turbo"\n',
214
+ );
215
+ const fakeDeepseek = path.join(tmp, "deepseek");
216
+ writeFileSync(
217
+ fakeDeepseek,
218
+ [
219
+ "binary prefix",
220
+ "# Thinking mode (DeepSeek V4 reasoning effort):",
221
+ '# "adaptive" | "disabled" | "turbo"',
222
+ 'reasoning_effort = "adaptive"',
223
+ ].join("\n"),
224
+ );
225
+
226
+ const catalog = discoverRuntimeModelCatalog({
227
+ id: "deepseek-tui",
228
+ displayName: "DeepSeek TUI",
229
+ binary: "deepseek",
230
+ supportsRun: true,
231
+ result: { available: true, path: fakeDeepseek },
232
+ });
233
+
234
+ expect(catalog.parameters).toContainEqual({
235
+ id: "reasoning_effort",
236
+ displayName: "Reasoning effort",
237
+ type: "enum",
238
+ flag: "--reasoning-effort",
239
+ values: ["adaptive", "disabled", "turbo"],
240
+ defaultValue: "turbo",
241
+ source: "config",
242
+ });
243
+ } finally {
244
+ if (prevHome === undefined) delete process.env.HOME;
245
+ else process.env.HOME = prevHome;
246
+ if (prevCacheDir === undefined) delete process.env.BOTCORD_RUNTIME_CATALOG_CACHE_DIR;
247
+ else process.env.BOTCORD_RUNTIME_CATALOG_CACHE_DIR = prevCacheDir;
248
+ rmSync(tmp, { recursive: true, force: true });
249
+ }
250
+ });
251
+
202
252
  it("parses Kimi models from config.toml", () => {
203
253
  expect(
204
254
  parseKimiConfigModels(
@@ -9,7 +9,8 @@
9
9
  *
10
10
  * See ``docs/cloud-agent-technical-design.md`` §4 + §6.
11
11
  */
12
- import { shouldWake, type AttentionPolicy } from "@botcord/protocol-core";
12
+ import { CONTROL_FRAME_TYPES, shouldWake, type AttentionPolicy } from "@botcord/protocol-core";
13
+ import type { CloudGatewayTypingEmitter } from "./cloud-gateway-runtime.js";
13
14
  import {
14
15
  Gateway,
15
16
  resolveTranscriptEnabled,
@@ -284,10 +285,33 @@ export async function startCloudDaemon(
284
285
  if (!opts.disableControlChannel) {
285
286
  const auth = asUserAuthManager(new CloudAuthManager(cloudCfg));
286
287
  const provisionerFactory = opts.provisionerFactory ?? createProvisioner;
288
+ // Forward-declare controlChannel-bound emitter: the provisioner is
289
+ // built before the channel exists, so the closure captures the slot
290
+ // and reads it back lazily once cloud-daemon assigns the instance.
291
+ const cloudGatewayTypingEmitter: CloudGatewayTypingEmitter = (event) => {
292
+ const ch = controlChannel;
293
+ if (!ch) return;
294
+ ch.send({
295
+ id: `cgrs_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
296
+ type: CONTROL_FRAME_TYPES.CLOUD_GATEWAY_RUNTIME_STATUS,
297
+ params: {
298
+ eventId: event.eventId,
299
+ turnId: event.turnId,
300
+ gatewayId: event.gatewayId,
301
+ agentId: event.agentId,
302
+ conversationId: event.conversationId,
303
+ kind: "typing",
304
+ phase: event.phase,
305
+ traceId: event.traceId ?? null,
306
+ },
307
+ ts: Date.now(),
308
+ });
309
+ };
287
310
  const provisioner = provisionerFactory({
288
311
  gateway,
289
312
  policyResolver,
290
313
  onAgentInstalled,
314
+ cloudGatewayTypingEmitter,
291
315
  });
292
316
  const ControlChannelCtor = opts.controlChannelFactory ?? ControlChannel;
293
317
  controlChannel = new ControlChannelCtor({
@@ -8,11 +8,31 @@ import type {
8
8
  ChannelSendContext,
9
9
  ChannelSendResult,
10
10
  ChannelStatusSnapshot,
11
+ ChannelTypingContext,
11
12
  Gateway,
12
13
  GatewayInboundMessage,
13
14
  GatewayLogger,
14
15
  } from "./gateway/index.js";
15
16
 
17
+ /**
18
+ * Fire-and-forget hook invoked when the dispatcher signals "typing" while
19
+ * a cloud-gateway runtime turn is in flight. The cloud daemon wires this
20
+ * to a control-plane `cloud_gateway_runtime_status` push so the Hub can
21
+ * relay it to the ingress runtime WS and the third-party provider can
22
+ * render a typing indicator.
23
+ */
24
+ export interface CloudGatewayTypingEvent {
25
+ eventId: string;
26
+ turnId: string;
27
+ gatewayId: string;
28
+ agentId: string;
29
+ conversationId: string;
30
+ phase: "started" | "stopped";
31
+ traceId?: string | null;
32
+ }
33
+
34
+ export type CloudGatewayTypingEmitter = (event: CloudGatewayTypingEvent) => void;
35
+
16
36
  export interface CloudGatewayRuntimeResult {
17
37
  accepted: boolean;
18
38
  eventId: string;
@@ -44,6 +64,7 @@ export async function handleCloudGatewayRuntimeInbound(
44
64
  gateway: Gateway,
45
65
  frame: GatewayInboundFrame,
46
66
  log?: GatewayLogger,
67
+ onTyping?: CloudGatewayTypingEmitter,
47
68
  ): Promise<CloudGatewayRuntimeResult> {
48
69
  if (frame.type !== RUNTIME_FRAME_TYPES.GATEWAY_INBOUND) {
49
70
  return rejected(frame, "bad_frame_type", `unsupported frame type "${frame.type}"`);
@@ -61,6 +82,7 @@ export async function handleCloudGatewayRuntimeInbound(
61
82
  let accepted = false;
62
83
  let outboundText: string | null = null;
63
84
  let providerMessageId: string | null | undefined;
85
+ const turnId = `turn_${frame.event_id}`;
64
86
  const channel = createRuntimeRelayChannel({
65
87
  id: frame.gateway_id,
66
88
  provider: frame.provider,
@@ -70,6 +92,28 @@ export async function handleCloudGatewayRuntimeInbound(
70
92
  providerMessageId = ctx.message.traceId ?? null;
71
93
  return { providerMessageId };
72
94
  },
95
+ onTyping: onTyping
96
+ ? (ctx) => {
97
+ try {
98
+ onTyping({
99
+ eventId: frame.event_id,
100
+ turnId,
101
+ gatewayId: frame.gateway_id,
102
+ agentId: frame.agent_id,
103
+ conversationId: frame.message.conversation.id,
104
+ phase: "started",
105
+ traceId: ctx.traceId ?? null,
106
+ });
107
+ } catch (err) {
108
+ log?.warn?.("cloud gateway typing emit failed", {
109
+ eventId: frame.event_id,
110
+ gatewayId: frame.gateway_id,
111
+ agentId: frame.agent_id,
112
+ error: err instanceof Error ? err.message : String(err),
113
+ });
114
+ }
115
+ }
116
+ : undefined,
73
117
  });
74
118
 
75
119
  const message: GatewayInboundMessage = {
@@ -105,7 +149,7 @@ export async function handleCloudGatewayRuntimeInbound(
105
149
  gatewayId: frame.gateway_id,
106
150
  agentId: frame.agent_id,
107
151
  conversationId: frame.message.conversation.id,
108
- turnId: `turn_${frame.event_id}`,
152
+ turnId,
109
153
  ...(outboundText !== null
110
154
  ? {
111
155
  outbound: {
@@ -125,9 +169,10 @@ function createRuntimeRelayChannel(opts: {
125
169
  provider: string;
126
170
  accountId: string;
127
171
  onSend: (ctx: ChannelSendContext) => Promise<ChannelSendResult>;
172
+ onTyping?: (ctx: ChannelTypingContext) => void;
128
173
  }): ChannelAdapter {
129
174
  let lastSendAt: number | undefined;
130
- return {
175
+ const adapter: ChannelAdapter = {
131
176
  id: opts.id,
132
177
  type: opts.provider,
133
178
  async start() {
@@ -152,6 +197,12 @@ function createRuntimeRelayChannel(opts: {
152
197
  };
153
198
  },
154
199
  };
200
+ if (opts.onTyping) {
201
+ adapter.typing = async (ctx) => {
202
+ opts.onTyping!(ctx);
203
+ };
204
+ }
205
+ return adapter;
155
206
  }
156
207
 
157
208
  function rejected(
package/src/provision.ts CHANGED
@@ -78,7 +78,10 @@ import {
78
78
  buildRuntimeSelectionExtraArgs,
79
79
  mergeRuntimeExtraArgs,
80
80
  } from "./runtime-route-options.js";
81
- import { handleCloudGatewayRuntimeInbound } from "./cloud-gateway-runtime.js";
81
+ import {
82
+ handleCloudGatewayRuntimeInbound,
83
+ type CloudGatewayTypingEmitter,
84
+ } from "./cloud-gateway-runtime.js";
82
85
 
83
86
  /**
84
87
  * Information passed to {@link OnAgentInstalledHook} after a successful
@@ -136,6 +139,13 @@ export interface ProvisionerOptions {
136
139
  * spin up a default in-memory store.
137
140
  */
138
141
  loginSessions?: LoginSessionStore;
142
+ /**
143
+ * Optional callback invoked when an in-flight `cloud_gateway_runtime_inbound`
144
+ * fires a typing event. The cloud daemon wires this to its control-plane
145
+ * connection so the Hub can relay the hint to the live ingress runtime WS.
146
+ * Absent for local daemons (no ingress path).
147
+ */
148
+ cloudGatewayTypingEmitter?: CloudGatewayTypingEmitter;
139
149
  }
140
150
 
141
151
  /** The value a frame handler returns (minus the `id` which the channel fills in). */
@@ -153,6 +163,7 @@ export function createProvisioner(opts: ProvisionerOptions): (
153
163
  const register = opts.register ?? BotCordClient.register;
154
164
  const policyResolver = opts.policyResolver;
155
165
  const onAgentInstalled = opts.onAgentInstalled;
166
+ const cloudGatewayTypingEmitter = opts.cloudGatewayTypingEmitter;
156
167
  const gatewayControl = createGatewayControl({
157
168
  gateway,
158
169
  ...(opts.loginSessions ? { loginSessions: opts.loginSessions } : {}),
@@ -440,7 +451,12 @@ export function createProvisioner(opts: ProvisionerOptions): (
440
451
  },
441
452
  };
442
453
  }
443
- const result = await handleCloudGatewayRuntimeInbound(gateway, runtimeFrame);
454
+ const result = await handleCloudGatewayRuntimeInbound(
455
+ gateway,
456
+ runtimeFrame,
457
+ undefined,
458
+ cloudGatewayTypingEmitter,
459
+ );
444
460
  return result.accepted
445
461
  ? { ok: true, result }
446
462
  : {
@@ -7,7 +7,7 @@ import type { RuntimeProbeEntry } from "./adapters/runtimes.js";
7
7
 
8
8
  const MODEL_LIST_TIMEOUT_MS = 5000;
9
9
  const MODEL_LIST_MAX_BUFFER = 16 * 1024 * 1024;
10
- const RUNTIME_CATALOG_CACHE_VERSION = 1;
10
+ const RUNTIME_CATALOG_CACHE_VERSION = 2;
11
11
  const RUNTIME_CATALOG_CACHE_FRESH_MS = 10 * 60 * 1000;
12
12
  const DEFAULT_RUNTIME_CATALOG_CACHE_DIR = path.join(
13
13
  homedir(),
@@ -136,7 +136,7 @@ function runtimeCatalogStrategy(entry: RuntimeProbeEntry): RuntimeCatalogStrateg
136
136
  discoverFresh: () => discoverDeepseekCatalog(entry.result.path),
137
137
  fallback: () => ({
138
138
  models: DEEPSEEK_FALLBACK_MODELS.slice(),
139
- parameters: discoverDeepseekParameters(),
139
+ parameters: discoverDeepseekParameters(entry.result.path),
140
140
  }),
141
141
  };
142
142
  case "kimi-cli":
@@ -433,7 +433,7 @@ function discoverCodexParameters(rawCatalog: string | null): RuntimeParameterPro
433
433
  function discoverDeepseekCatalog(command: string | undefined): RuntimeModelDiscovery {
434
434
  return {
435
435
  models: discoverDeepseekModels(command),
436
- parameters: discoverDeepseekParameters(),
436
+ parameters: discoverDeepseekParameters(command),
437
437
  };
438
438
  }
439
439
 
@@ -457,8 +457,9 @@ export function parseDeepseekModelList(raw: string): RuntimeModelProbe[] | undef
457
457
  return out.length ? out : undefined;
458
458
  }
459
459
 
460
- function discoverDeepseekParameters(): RuntimeParameterProbe[] {
460
+ function discoverDeepseekParameters(command?: string): RuntimeParameterProbe[] {
461
461
  const config = readConfigScalars(path.join(homedir(), ".deepseek", "config.toml"));
462
+ const reasoningEffortValues = discoverDeepseekReasoningEffortValues(command);
462
463
  return [
463
464
  compactParameter({
464
465
  id: "model",
@@ -480,8 +481,9 @@ function discoverDeepseekParameters(): RuntimeParameterProbe[] {
480
481
  compactParameter({
481
482
  id: "reasoning_effort",
482
483
  displayName: "Reasoning effort",
483
- type: "string",
484
- flag: "reasoning_effort",
484
+ type: reasoningEffortValues.length > 0 ? "enum" : "string",
485
+ flag: "--reasoning-effort",
486
+ values: reasoningEffortValues.length > 0 ? reasoningEffortValues : undefined,
485
487
  defaultValue: config.reasoning_effort,
486
488
  source: config.reasoning_effort ? "config" : "cli",
487
489
  }),
@@ -504,6 +506,44 @@ function discoverDeepseekParameters(): RuntimeParameterProbe[] {
504
506
  ];
505
507
  }
506
508
 
509
+ function discoverDeepseekReasoningEffortValues(command: string | undefined): string[] {
510
+ const candidates = deepseekRuntimeTemplateCandidates(command);
511
+ const values = new Set<string>();
512
+ for (const candidate of candidates) {
513
+ try {
514
+ const raw = readFileSync(candidate)
515
+ .toString("latin1")
516
+ .replace(/[^\x20-\x7E]+/g, "\n");
517
+ const templateRe =
518
+ /Thinking mode \(DeepSeek V4 reasoning effort\):[\s\S]{0,256}?#\s*((?:"[^"]+"\s*(?:\|\s*)?)+)/g;
519
+ for (const match of raw.matchAll(templateRe)) {
520
+ const line = match[1] ?? "";
521
+ for (const valueMatch of line.matchAll(/"([^"]+)"/g)) {
522
+ const value = valueMatch[1]?.trim();
523
+ if (value && /^[A-Za-z0-9_.-]+$/.test(value)) values.add(value);
524
+ }
525
+ }
526
+ } catch {
527
+ // Try the next candidate; runtime discovery should stay best-effort.
528
+ }
529
+ }
530
+ return Array.from(values);
531
+ }
532
+
533
+ function deepseekRuntimeTemplateCandidates(command: string | undefined): string[] {
534
+ if (!command) return [];
535
+ const candidates = new Set<string>();
536
+ if (existsSync(command)) candidates.add(command);
537
+ const dir = path.dirname(command);
538
+ for (const candidate of [
539
+ path.join(dir, "deepseek-tui"),
540
+ path.join(dir, "downloads", "deepseek-tui"),
541
+ ]) {
542
+ if (existsSync(candidate)) candidates.add(candidate);
543
+ }
544
+ return Array.from(candidates);
545
+ }
546
+
507
547
  function discoverKimiCatalog(): RuntimeModelDiscovery {
508
548
  const configPath = path.join(homedir(), ".kimi", "config.toml");
509
549
  if (!existsSync(configPath)) return {};