@botcord/daemon 0.2.83 → 0.2.84

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
  : {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botcord/daemon",
3
- "version": "0.2.83",
3
+ "version": "0.2.84",
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: {
@@ -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
  : {