@rynx-ai/daemon 0.1.10 → 0.1.11-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/bundled-plugins/.rynx-plugin/marketplace.json +10 -0
  2. package/bundled-plugins/plugins/lark/.rynx-plugin/plugin.json +37 -0
  3. package/bundled-plugins/plugins/lark/dist/cli-mirror/mirror-index.d.ts +48 -0
  4. package/bundled-plugins/plugins/lark/dist/cli-mirror/mirror-index.js +54 -0
  5. package/bundled-plugins/plugins/lark/dist/cli-mirror/rollout-mirror.d.ts +85 -0
  6. package/bundled-plugins/plugins/lark/dist/cli-mirror/rollout-mirror.js +333 -0
  7. package/bundled-plugins/plugins/lark/dist/cli-mirror/rollout-watcher.d.ts +109 -0
  8. package/bundled-plugins/plugins/lark/dist/cli-mirror/rollout-watcher.js +347 -0
  9. package/bundled-plugins/plugins/lark/dist/host-adapters.d.ts +95 -0
  10. package/bundled-plugins/plugins/lark/dist/host-adapters.js +504 -0
  11. package/bundled-plugins/plugins/lark/dist/index.d.ts +7 -0
  12. package/bundled-plugins/plugins/lark/dist/index.js +7 -0
  13. package/bundled-plugins/plugins/lark/dist/lark-card-stream.d.ts +363 -0
  14. package/bundled-plugins/plugins/lark/dist/lark-card-stream.js +1335 -0
  15. package/bundled-plugins/plugins/lark/dist/lark-config-store.d.ts +30 -0
  16. package/bundled-plugins/plugins/lark/dist/lark-config-store.js +135 -0
  17. package/bundled-plugins/plugins/lark/dist/lark-content.d.ts +30 -0
  18. package/bundled-plugins/plugins/lark/dist/lark-content.js +138 -0
  19. package/bundled-plugins/plugins/lark/dist/lark-conversation-directory.d.ts +65 -0
  20. package/bundled-plugins/plugins/lark/dist/lark-conversation-directory.js +106 -0
  21. package/bundled-plugins/plugins/lark/dist/lark-diff-card.d.ts +25 -0
  22. package/bundled-plugins/plugins/lark/dist/lark-diff-card.js +58 -0
  23. package/bundled-plugins/plugins/lark/dist/lark-fork-transcript.d.ts +16 -0
  24. package/bundled-plugins/plugins/lark/dist/lark-fork-transcript.js +141 -0
  25. package/bundled-plugins/plugins/lark/dist/lark-resume-card.d.ts +31 -0
  26. package/bundled-plugins/plugins/lark/dist/lark-resume-card.js +105 -0
  27. package/bundled-plugins/plugins/lark/dist/lark-runtime-state-store.d.ts +18 -0
  28. package/bundled-plugins/plugins/lark/dist/lark-runtime-state-store.js +95 -0
  29. package/bundled-plugins/plugins/lark/dist/lark-sdk/client.d.ts +8 -0
  30. package/bundled-plugins/plugins/lark/dist/lark-sdk/client.js +32 -0
  31. package/bundled-plugins/plugins/lark/dist/lark-sdk/index.d.ts +1 -0
  32. package/bundled-plugins/plugins/lark/dist/lark-sdk/index.js +1 -0
  33. package/bundled-plugins/plugins/lark/dist/lark-sender.d.ts +114 -0
  34. package/bundled-plugins/plugins/lark/dist/lark-sender.js +323 -0
  35. package/bundled-plugins/plugins/lark/dist/lark-session-store.d.ts +13 -0
  36. package/bundled-plugins/plugins/lark/dist/lark-session-store.js +14 -0
  37. package/bundled-plugins/plugins/lark/dist/lark-settings-card.d.ts +55 -0
  38. package/bundled-plugins/plugins/lark/dist/lark-settings-card.js +158 -0
  39. package/bundled-plugins/plugins/lark/dist/lark-setup.d.ts +39 -0
  40. package/bundled-plugins/plugins/lark/dist/lark-setup.js +103 -0
  41. package/bundled-plugins/plugins/lark/dist/lark.d.ts +507 -0
  42. package/bundled-plugins/plugins/lark/dist/lark.js +1977 -0
  43. package/bundled-plugins/plugins/lark/dist/runtime.d.ts +13 -0
  44. package/bundled-plugins/plugins/lark/dist/runtime.js +131316 -0
  45. package/bundled-plugins/plugins/lark/dist/settings.d.ts +7 -0
  46. package/bundled-plugins/plugins/lark/dist/settings.js +126958 -0
  47. package/bundled-plugins/plugins/lark/package.json +57 -0
  48. package/bundled-plugins/plugins/lark/ui/assets/index-B2dhMlMU.css +1 -0
  49. package/bundled-plugins/plugins/lark/ui/assets/index-D9N-AkLX.js +201 -0
  50. package/bundled-plugins/plugins/lark/ui/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  51. package/bundled-plugins/plugins/lark/ui/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  52. package/bundled-plugins/plugins/lark/ui/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  53. package/bundled-plugins/plugins/lark/ui/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  54. package/bundled-plugins/plugins/lark/ui/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  55. package/bundled-plugins/plugins/lark/ui/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  56. package/bundled-plugins/plugins/lark/ui/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  57. package/bundled-plugins/plugins/lark/ui/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  58. package/bundled-plugins/plugins/lark/ui/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  59. package/bundled-plugins/plugins/lark/ui/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  60. package/bundled-plugins/plugins/lark/ui/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  61. package/bundled-plugins/plugins/lark/ui/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  62. package/bundled-plugins/plugins/lark/ui/index.html +13 -0
  63. package/dist/bundled-plugins.d.ts +15 -0
  64. package/dist/bundled-plugins.js +134 -0
  65. package/dist/cli.js +26 -208
  66. package/dist/control-deps.js +2 -79
  67. package/dist/daemon-server.d.ts +4 -1
  68. package/dist/daemon-server.js +167 -57
  69. package/dist/db.js +145 -124
  70. package/dist/index-daemon.js +25 -4
  71. package/dist/migrations/cleanup-legacy-sessions.d.ts +1 -1
  72. package/dist/migrations/cleanup-legacy-sessions.js +33 -15
  73. package/dist/official-plugins.d.ts +2 -0
  74. package/dist/official-plugins.js +2 -0
  75. package/dist/plugin-cli.d.ts +1 -3
  76. package/dist/plugin-cli.js +56 -45
  77. package/dist/plugin-host-leases.d.ts +4 -0
  78. package/dist/plugin-host-leases.js +7 -0
  79. package/dist/plugin-host-rpc.d.ts +42 -4
  80. package/dist/plugin-host-rpc.js +144 -34
  81. package/dist/plugin-installer.d.ts +24 -55
  82. package/dist/plugin-installer.js +202 -415
  83. package/dist/plugin-management-service.d.ts +30 -23
  84. package/dist/plugin-management-service.js +290 -105
  85. package/dist/plugin-marketplace.d.ts +35 -0
  86. package/dist/plugin-marketplace.js +299 -0
  87. package/dist/plugin-package.d.ts +22 -24
  88. package/dist/plugin-package.js +520 -571
  89. package/dist/plugin-skill-context.d.ts +4 -0
  90. package/dist/plugin-skill-context.js +35 -0
  91. package/dist/plugin-store.d.ts +36 -37
  92. package/dist/plugin-store.js +140 -92
  93. package/dist/plugin-supervisor.d.ts +45 -125
  94. package/dist/plugin-supervisor.js +516 -1053
  95. package/dist/pm2.js +15 -10
  96. package/dist/runtime-project-store.d.ts +9 -0
  97. package/dist/runtime-project-store.js +83 -0
  98. package/dist/session-fallback-workspace.d.ts +22 -0
  99. package/dist/session-fallback-workspace.js +120 -0
  100. package/dist/session-fork-store.d.ts +41 -0
  101. package/dist/session-fork-store.js +288 -0
  102. package/dist/session-launch-operations.d.ts +14 -2
  103. package/dist/session-launch-operations.js +34 -2
  104. package/dist/session-meta-store.d.ts +7 -9
  105. package/dist/session-meta-store.js +28 -49
  106. package/dist/setup.js +26 -14
  107. package/package.json +14 -11
  108. package/dist/channel-store.d.ts +0 -22
  109. package/dist/channel-store.js +0 -65
  110. package/dist/instance-store.d.ts +0 -38
  111. package/dist/instance-store.js +0 -85
  112. package/dist/plugin-install-restart.d.ts +0 -13
  113. package/dist/plugin-install-restart.js +0 -24
  114. package/dist/plugin-runtime-control.d.ts +0 -2
  115. package/dist/plugin-runtime-control.js +0 -37
  116. package/dist/registry.d.ts +0 -10
  117. package/dist/registry.js +0 -52
@@ -0,0 +1,1977 @@
1
+ import { execFile } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import path from "node:path";
4
+ import { promisify } from "node:util";
5
+ import { CodexRuntimeError, collectLiveRun, } from "@rynx-ai/core";
6
+ const execFileAsync = promisify(execFile);
7
+ import { LarkSessionCardController, } from "./lark-card-stream.js";
8
+ import { buildLarkCardReplyPrompt, chunkLarkReplyText, extractLarkTextPrompt, isLarkNewSessionCommand, isLarkStopCommand, parseLarkCommand, } from "./lark-content.js";
9
+ import { deriveConversationShortId, MemoryLarkConversationDirectory, } from "./lark-conversation-directory.js";
10
+ import { buildDiffCardJson } from "./lark-diff-card.js";
11
+ import { buildForkTranscript } from "./lark-fork-transcript.js";
12
+ import { buildChatApplink, buildOpenChatCardJson, buildResumeCardJson, } from "./lark-resume-card.js";
13
+ import { LarkClientProvider, LarkSdkMessageSender } from "./lark-sender.js";
14
+ import { buildSettingsCard, buildSettingsCardJson, buildSettingsResultCard, SETTINGS_DEFAULT_VALUE, SETTINGS_EFFORT_VALUES, } from "./lark-settings-card.js";
15
+ import { getRuntimeProfile, isAgentRuntimeId, RUNTIME_PROFILES, } from "@rynx-ai/core";
16
+ import { SteerController } from "@rynx-ai/core";
17
+ import { toErrorMessage } from "@rynx-ai/core";
18
+ export { chunkLarkReplyText, extractLarkTextPrompt, isLarkNewSessionCommand, isLarkStopCommand, };
19
+ export { LarkClientProvider, LarkSdkMessageSender };
20
+ const DEFAULT_UNSUPPORTED_MESSAGE = "暂时只支持文本消息,请发送文本内容。";
21
+ const DEFAULT_EMPTY_MESSAGE = "请发送文本内容。";
22
+ const DEFAULT_EMPTY_RESPONSE = "抱歉,我暂时没有生成回复。";
23
+ const DEFAULT_NEW_SESSION_RESPONSE = "已开启新会话。";
24
+ const DEFAULT_STOP_NO_INFLIGHT_RESPONSE = "当前没有正在进行的任务。";
25
+ const DEFAULT_STOP_ACK_RESPONSE = "已停止当前任务。";
26
+ const LARK_WORK_STARTED_REACTION = "Get";
27
+ const LARK_WORK_COMPLETED_REACTION = "DONE";
28
+ /** Reaction added to a message whose text was steered into the running turn. */
29
+ const LARK_STEER_ACK_REACTION = "Get";
30
+ const LARK_COMMANDS = [
31
+ { name: "new", help: "/new — 开启新会话", category: "takeover" },
32
+ { name: "stop", help: "/stop 或 /cancel — 停止当前正在进行的任务", category: "other" },
33
+ { name: "runtime", help: "/runtime — 卡片切换运行时(codex/traex/claude)/ 模型 / 思考强度", category: "other" },
34
+ { name: "model", help: "/model — 卡片切换当前运行时的模型 / 思考强度", category: "other" },
35
+ { name: "agent", help: "/agent [名称] — 列出/切换声明式 agent(运行时+模型+提示+权限的预设)", category: "other" },
36
+ { name: "models", help: "/models — 列出当前运行时可用模型", category: "info" },
37
+ { name: "resume", help: "/resume [短id] — 列出最近会话/跳转或转发到指定会话", category: "takeover" },
38
+ { name: "fork", help: "/fork — 把当前会话 fork 到新群/新话题(带上下文+历史记录)", category: "takeover" },
39
+ { name: "cwd", help: "/cwd — 工作目录已改由 Rynx Project 管理", category: "other" },
40
+ { name: "goal", help: "/goal [目标|clear] — 查看/设置/清除当前会话目标", category: "action" },
41
+ { name: "diff", help: "/diff — 显示当前工作区 git 改动(含未跟踪文件)", category: "action" },
42
+ { name: "help", help: "/help — 显示本帮助", category: "info" },
43
+ ];
44
+ function larkCommandNames(category) {
45
+ return new Set(LARK_COMMANDS.filter((c) => c.category === category).map((c) => c.name));
46
+ }
47
+ const LARK_TAKEOVER_COMMANDS = larkCommandNames("takeover");
48
+ const LARK_INFO_COMMANDS = larkCommandNames("info");
49
+ const LARK_ACTION_COMMANDS = larkCommandNames("action");
50
+ const LARK_HELP_TEXT = ["可用命令:", ...LARK_COMMANDS.map((c) => c.help)].join("\n");
51
+ const LARK_BACKEND_UNSUPPORTED = "当前后端不支持该命令(仅 app_server 后端可用)。";
52
+ const LARK_NO_ACTIVE_SESSION = "请先发一条消息开始会话,再使用该命令。";
53
+ const DIFF_REPLY_MAX_BYTES = 60 * 1024;
54
+ /** Max diff body embedded in the `/diff` card (kept under Feishu's card size limit). */
55
+ const DIFF_CARD_MAX_BYTES = 16 * 1024;
56
+ const defaultLogger = {
57
+ log(entry) {
58
+ console.log(JSON.stringify({
59
+ level: "info",
60
+ type: "lark",
61
+ ...entry,
62
+ }));
63
+ },
64
+ };
65
+ /** Route SDK diagnostics through the host logger; the process transport owns stdout. */
66
+ function larkSdkLogger(logger) {
67
+ const write = (level, messages) => {
68
+ const text = messages
69
+ .map((message) => (message instanceof Error ? message.message : String(message)))
70
+ .join(" ")
71
+ .replace(/[A-Za-z0-9_-]{30,}/g, "***");
72
+ logger.log({ event: `sdk.${level}`, error: text });
73
+ };
74
+ return {
75
+ error: (...messages) => write("error", messages),
76
+ warn: (...messages) => write("warn", messages),
77
+ info: () => undefined,
78
+ debug: () => undefined,
79
+ trace: () => undefined,
80
+ };
81
+ }
82
+ export class LarkConversationKeyResolver {
83
+ resolve(event) {
84
+ const tenantKey = event.tenant_key ?? event.sender.tenant_key ?? "unknown-tenant";
85
+ const threadId = event.message.thread_id?.trim();
86
+ if (threadId) {
87
+ return `lark:${tenantKey}:thread:${threadId}`;
88
+ }
89
+ return `lark:${tenantKey}:chat:${event.message.chat_id}`;
90
+ }
91
+ }
92
+ export class PerThreadQueue {
93
+ tails = new Map();
94
+ enqueue(key, task) {
95
+ const previous = this.tails.get(key) ?? Promise.resolve();
96
+ const next = previous
97
+ .catch(() => undefined)
98
+ .then(task);
99
+ const tracked = next.finally(() => {
100
+ if (this.tails.get(key) === tracked) {
101
+ this.tails.delete(key);
102
+ }
103
+ });
104
+ this.tails.set(key, tracked);
105
+ return tracked;
106
+ }
107
+ }
108
+ export class LarkMessageDeduper {
109
+ ttlMs;
110
+ entries = new Map();
111
+ constructor(ttlMs = 60 * 60 * 1000) {
112
+ this.ttlMs = ttlMs;
113
+ }
114
+ markIfNew(messageId, now = Date.now()) {
115
+ this.prune(now);
116
+ const expiresAt = this.entries.get(messageId);
117
+ if (expiresAt && expiresAt > now) {
118
+ return false;
119
+ }
120
+ this.entries.set(messageId, now + this.ttlMs);
121
+ return true;
122
+ }
123
+ prune(now) {
124
+ for (const [messageId, expiresAt] of this.entries) {
125
+ if (expiresAt <= now) {
126
+ this.entries.delete(messageId);
127
+ }
128
+ }
129
+ }
130
+ }
131
+ /** One-line title for a session's registry row: collapse whitespace, trim, cap
132
+ * at 60 chars + ellipsis. Empty prompt ⇒ undefined (no title). */
133
+ function larkSessionTitle(prompt) {
134
+ const text = prompt.replace(/\s+/g, " ").trim();
135
+ if (!text)
136
+ return undefined;
137
+ return text.length <= 60 ? text : `${text.slice(0, 59)}…`;
138
+ }
139
+ /**
140
+ * Pull a model's supported reasoning efforts from a `model/list` entry,
141
+ * tolerating both the App Server camelCase (`supportedReasoningEfforts`) and the
142
+ * cache/snake (`supported_reasoning_levels`) spellings. Empty when unknown.
143
+ */
144
+ function extractSupportedEfforts(model) {
145
+ const raw = model.supportedReasoningEfforts ??
146
+ model.supported_reasoning_levels ??
147
+ model.supportedReasoningLevels;
148
+ if (!Array.isArray(raw)) {
149
+ return [];
150
+ }
151
+ const efforts = [];
152
+ const seen = new Set();
153
+ for (const item of raw) {
154
+ const record = item && typeof item === "object" && !Array.isArray(item)
155
+ ? item
156
+ : undefined;
157
+ const effort = typeof item === "string"
158
+ ? item.trim()
159
+ : typeof record?.reasoningEffort === "string"
160
+ ? record.reasoningEffort.trim()
161
+ : typeof record?.effort === "string"
162
+ ? record.effort.trim()
163
+ : "";
164
+ if (!/^[a-zA-Z0-9_-]{1,64}$/.test(effort) || seen.has(effort))
165
+ continue;
166
+ seen.add(effort);
167
+ efforts.push(effort);
168
+ }
169
+ return efforts;
170
+ }
171
+ export class LarkMessageIngestor {
172
+ conversationRuntime;
173
+ messageSender;
174
+ /** Default runtime for this bot; per-conversation choices override it. */
175
+ provider;
176
+ /** Default declarative agent for this bot; per-conversation `/agent` overrides it. */
177
+ defaultAgent;
178
+ /** Host-backed agent catalog for `/agent` and execution freezing. */
179
+ agentCatalog;
180
+ /** Transient per-conversation settings-card selections, keyed by sessionKey. */
181
+ pendingSettings = new Map();
182
+ cwd;
183
+ activeSessionStore;
184
+ conversationKeyResolver;
185
+ deduper;
186
+ queue;
187
+ logger;
188
+ cardThrottleMs;
189
+ capabilities;
190
+ conversationDirectory;
191
+ allowedRoots;
192
+ botOpenId;
193
+ disableP2p;
194
+ groupAllowlistEnabled;
195
+ groupAllowlist;
196
+ mirror;
197
+ sessionTitleWriter;
198
+ sessionProvisioner;
199
+ inFlight = new Map();
200
+ constructor({ conversationRuntime, messageSender, cwd, defaultRuntime = "codex", defaultAgent, agentCatalog, activeSessionStore, conversationKeyResolver = new LarkConversationKeyResolver(), deduper = new LarkMessageDeduper(), queue = new PerThreadQueue(), logger = defaultLogger, cardThrottleMs = 250, capabilities, conversationDirectory = new MemoryLarkConversationDirectory(), allowedRoots = [], botOpenId = null, disableP2p = false, groupAllowlistEnabled = false, groupAllowlist = [], mirror, sessionTitleWriter, sessionProvisioner, }) {
201
+ this.conversationRuntime = conversationRuntime;
202
+ this.messageSender = messageSender;
203
+ this.provider = defaultRuntime;
204
+ this.defaultAgent = defaultAgent;
205
+ this.agentCatalog = agentCatalog;
206
+ this.cwd = cwd;
207
+ this.activeSessionStore = activeSessionStore;
208
+ this.conversationKeyResolver = conversationKeyResolver;
209
+ this.deduper = deduper;
210
+ this.queue = queue;
211
+ this.logger = logger;
212
+ this.cardThrottleMs = cardThrottleMs;
213
+ this.capabilities = capabilities;
214
+ this.conversationDirectory = conversationDirectory;
215
+ this.allowedRoots = allowedRoots;
216
+ this.botOpenId = botOpenId ?? null;
217
+ this.disableP2p = disableP2p;
218
+ this.groupAllowlistEnabled = groupAllowlistEnabled;
219
+ this.groupAllowlist = new Set(groupAllowlist);
220
+ this.mirror = mirror;
221
+ this.sessionTitleWriter = sessionTitleWriter;
222
+ this.sessionProvisioner = sessionProvisioner;
223
+ }
224
+ /** Best-effort title update for the daemon-owned machine-session record. */
225
+ registerSession(sessionId, prompt) {
226
+ const title = larkSessionTitle(prompt);
227
+ if (!this.sessionTitleWriter || !title)
228
+ return;
229
+ void this.sessionTitleWriter(sessionId, title).catch((error) => {
230
+ this.logger.log({
231
+ event: "session_meta.title_failed",
232
+ session_id: sessionId,
233
+ error: toErrorMessage(error),
234
+ });
235
+ });
236
+ }
237
+ /**
238
+ * Set this bot's own `open_id` once it has been resolved asynchronously
239
+ * (the runtime fetches it from the bot info API at startup).
240
+ */
241
+ setBotOpenId(openId) {
242
+ this.botOpenId = openId ?? null;
243
+ }
244
+ /** Whether a bridge-driven turn is currently running for `sessionKey`. The
245
+ * CLI mirror reads this to skip turns already shown live in Feishu. */
246
+ isInFlight(sessionKey) {
247
+ return this.inFlight.has(sessionKey);
248
+ }
249
+ /**
250
+ * Whether a group message is addressed to *this* bot. With a known
251
+ * `botOpenId`, that means one of the `@`-mentions carries our `open_id` —
252
+ * so in a group hosting several bots, only the one actually mentioned runs.
253
+ * When `botOpenId` is unknown, fall back to "any mention" (legacy single-bot
254
+ * behavior) rather than going silent.
255
+ */
256
+ isAddressedToBot(event) {
257
+ const mentions = event.message.mentions ?? [];
258
+ if (!this.botOpenId) {
259
+ return mentions.length > 0;
260
+ }
261
+ return mentions.some((mention) => mention.id?.open_id === this.botOpenId);
262
+ }
263
+ /**
264
+ * Handle a Feishu CardKit button click. Supported actions:
265
+ * - `{ kind: "stop_turn", sessionKey }` — the in-card "停止" button.
266
+ * - `{ kind: "resume_forward", threadId, chatId }` — forward a topic thread
267
+ * into `chatId` (from the `/resume` card).
268
+ * Anything else is logged and ignored.
269
+ */
270
+ async ingestCardAction(event) {
271
+ const value = event.value;
272
+ const kind = typeof value?.kind === "string" ? value.kind : null;
273
+ if (kind === "resume_forward") {
274
+ return this.handleResumeForwardAction(event);
275
+ }
276
+ if (kind === "settings_select") {
277
+ return this.handleSettingsSelect(event);
278
+ }
279
+ if (kind === "settings_apply") {
280
+ return this.handleSettingsApply(event);
281
+ }
282
+ if (kind === "settings_cancel") {
283
+ return this.handleSettingsCancel(event);
284
+ }
285
+ if (kind !== "stop_turn") {
286
+ this.logger.log({
287
+ event: "card.action.unknown",
288
+ kind,
289
+ message_id: event.messageId,
290
+ });
291
+ return {};
292
+ }
293
+ const sessionKey = typeof value?.sessionKey === "string" ? value.sessionKey : null;
294
+ if (!sessionKey) {
295
+ this.logger.log({
296
+ event: "card.action.missing_session_key",
297
+ message_id: event.messageId,
298
+ });
299
+ return {};
300
+ }
301
+ const aborted = this.interruptSession(sessionKey);
302
+ this.logger.log({
303
+ event: aborted ? "session.stopped" : "session.stop_noop",
304
+ message_id: event.messageId,
305
+ session_key: sessionKey,
306
+ provider: this.provider,
307
+ source: "card_button",
308
+ });
309
+ // No toast — the cancellation appears as a grey blockquote at the bottom
310
+ // of the card via the executor's CodexRuntimeError → finalize path.
311
+ return {};
312
+ }
313
+ async handleResumeForwardAction(event) {
314
+ const value = event.value;
315
+ const sourceThreadId = typeof value?.sourceThreadId === "string" ? value.sourceThreadId : null;
316
+ const targetChatId = typeof value?.targetChatId === "string" ? value.targetChatId : null;
317
+ const targetThreadId = typeof value?.targetThreadId === "string" ? value.targetThreadId : null;
318
+ if (!sourceThreadId || !this.messageSender.forwardThread) {
319
+ return { toast: { type: "error", content: "无法转发该话题。" } };
320
+ }
321
+ // Forward into the current thread when /resume was invoked inside one, so
322
+ // it lands in-place instead of spawning a new thread in the topic group.
323
+ const receiveIdType = targetThreadId ? "thread_id" : "chat_id";
324
+ const receiveId = targetThreadId ?? targetChatId;
325
+ if (!receiveId) {
326
+ return { toast: { type: "error", content: "无法转发该话题。" } };
327
+ }
328
+ try {
329
+ await this.messageSender.forwardThread({
330
+ threadId: sourceThreadId,
331
+ receiveId,
332
+ receiveIdType,
333
+ uuid: randomUUID(),
334
+ });
335
+ this.logger.log({
336
+ event: "resume.forwarded",
337
+ message_id: event.messageId,
338
+ thread_id: sourceThreadId,
339
+ chat_id: receiveId,
340
+ });
341
+ return { toast: { type: "success", content: "已转发到当前会话" } };
342
+ }
343
+ catch (error) {
344
+ return { toast: { type: "error", content: `转发失败:${toErrorMessage(error)}` } };
345
+ }
346
+ }
347
+ /** Where a forward should land for the conversation `event` arrived in:
348
+ * inside the current topic thread when present, else the chat. */
349
+ forwardTargetOf(event) {
350
+ if (event.message.thread_id) {
351
+ return { receiveId: event.message.thread_id, receiveIdType: "thread_id" };
352
+ }
353
+ return { receiveId: event.message.chat_id, receiveIdType: "chat_id" };
354
+ }
355
+ /**
356
+ * Cancel the in-flight turn for `sessionKey`, if any. Returns true if a
357
+ * turn was actually aborted. Safe to call from outside the per-session
358
+ * queue (that's the whole point — the queue is serialized but interrupt
359
+ * must bypass it).
360
+ */
361
+ interruptSession(sessionKey) {
362
+ const turn = this.inFlight.get(sessionKey);
363
+ if (!turn) {
364
+ return false;
365
+ }
366
+ turn.abort.abort();
367
+ return true;
368
+ }
369
+ registerInFlight(sessionKey) {
370
+ const existing = this.inFlight.get(sessionKey);
371
+ if (existing) {
372
+ // Per-session queue should prevent overlap, but be defensive: abort
373
+ // any prior controller so it doesn't leak.
374
+ existing.abort.abort();
375
+ }
376
+ const turn = {
377
+ abort: new AbortController(),
378
+ steer: new SteerController(),
379
+ };
380
+ this.inFlight.set(sessionKey, turn);
381
+ return turn;
382
+ }
383
+ clearInFlight(sessionKey, turn) {
384
+ if (this.inFlight.get(sessionKey) === turn) {
385
+ this.inFlight.delete(sessionKey);
386
+ }
387
+ }
388
+ async ingest(event) {
389
+ const sessionKey = this.conversationKeyResolver.resolve(event);
390
+ const prompt = ["text", "post"].includes(event.message.message_type)
391
+ ? extractLarkTextPrompt(event)
392
+ : "";
393
+ if (!this.deduper.markIfNew(event.message.message_id)) {
394
+ this.logger.log({
395
+ event: "message.duplicate",
396
+ tenant_key: event.tenant_key ?? event.sender.tenant_key,
397
+ chat_id: event.message.chat_id,
398
+ thread_id: event.message.thread_id,
399
+ message_id: event.message.message_id,
400
+ session_key: sessionKey,
401
+ });
402
+ return;
403
+ }
404
+ // Access control: optionally forbid private (p2p) chats so the bot is
405
+ // group-only. p2p chats are always "addressed" to the bot, so a notice is
406
+ // sent back rather than going silently dark.
407
+ if (event.message.chat_type !== "group" && this.disableP2p) {
408
+ this.logger.log({
409
+ event: "message.access_denied",
410
+ tenant_key: event.tenant_key ?? event.sender.tenant_key,
411
+ chat_id: event.message.chat_id,
412
+ thread_id: event.message.thread_id,
413
+ message_id: event.message.message_id,
414
+ session_key: sessionKey,
415
+ reason: "p2p_disabled",
416
+ });
417
+ await this.sendReplyText(event, "私聊已被管理员禁用,请在授权的群聊中使用本机器人。").catch(() => { });
418
+ return;
419
+ }
420
+ // In a group, only run when *this* bot is the one `@`-mentioned. A group can
421
+ // host several bots, and every bot receives the message event — without this
422
+ // check, an `@` aimed at one bot would make all of them reply.
423
+ if (event.message.chat_type === "group" && !this.isAddressedToBot(event)) {
424
+ this.logger.log({
425
+ event: "message.ignored",
426
+ tenant_key: event.tenant_key ?? event.sender.tenant_key,
427
+ chat_id: event.message.chat_id,
428
+ thread_id: event.message.thread_id,
429
+ message_id: event.message.message_id,
430
+ session_key: sessionKey,
431
+ reason: (event.message.mentions?.length ?? 0) === 0
432
+ ? "group_message_without_mention"
433
+ : "group_message_not_addressed_to_bot",
434
+ });
435
+ return;
436
+ }
437
+ // Access control: when the group allowlist is enabled, only run in groups
438
+ // whose chat_id is listed. Checked after the mention gate above, so we only
439
+ // reply to messages actually addressed to this bot (not every group chatter).
440
+ if (event.message.chat_type === "group" &&
441
+ this.groupAllowlistEnabled &&
442
+ !this.groupAllowlist.has(event.message.chat_id)) {
443
+ this.logger.log({
444
+ event: "message.access_denied",
445
+ tenant_key: event.tenant_key ?? event.sender.tenant_key,
446
+ chat_id: event.message.chat_id,
447
+ thread_id: event.message.thread_id,
448
+ message_id: event.message.message_id,
449
+ session_key: sessionKey,
450
+ reason: "group_not_allowlisted",
451
+ });
452
+ await this.sendReplyText(event, "本群未在机器人白名单内,暂不可使用。").catch(() => { });
453
+ return;
454
+ }
455
+ // /stop bypasses the per-session queue on purpose — the in-flight turn
456
+ // is what we want to abort, and that turn is currently holding the queue.
457
+ if (isLarkStopCommand(prompt)) {
458
+ await this.handleStopCommand(event, sessionKey);
459
+ return;
460
+ }
461
+ const command = parseLarkCommand(prompt);
462
+ // While a turn is already running for this session, a new message acts on
463
+ // that turn instead of queueing behind it:
464
+ // - a takeover command (/new /cwd /fork /resume) stops the running turn,
465
+ // then executes via its handler below (/stop is handled above);
466
+ // - plain text is injected into the running turn via `turn/steer`.
467
+ const inflight = this.inFlight.get(sessionKey);
468
+ if (inflight) {
469
+ if (command && LARK_TAKEOVER_COMMANDS.has(command.name)) {
470
+ if (this.interruptSession(sessionKey)) {
471
+ this.logger.log({
472
+ event: "session.stopped",
473
+ tenant_key: event.tenant_key ?? event.sender.tenant_key,
474
+ chat_id: event.message.chat_id,
475
+ thread_id: event.message.thread_id,
476
+ message_id: event.message.message_id,
477
+ session_key: sessionKey,
478
+ provider: this.provider,
479
+ source: "command_takeover",
480
+ command: command.name,
481
+ });
482
+ }
483
+ }
484
+ else if (!command && prompt) {
485
+ if (inflight.steer.send(prompt)) {
486
+ await this.tryCreateReaction(event, sessionKey, LARK_STEER_ACK_REACTION);
487
+ this.logger.log({
488
+ event: "session.steered",
489
+ tenant_key: event.tenant_key ?? event.sender.tenant_key,
490
+ chat_id: event.message.chat_id,
491
+ thread_id: event.message.thread_id,
492
+ message_id: event.message.message_id,
493
+ session_key: sessionKey,
494
+ provider: this.provider,
495
+ });
496
+ return;
497
+ }
498
+ // Backend can't steer (e.g. the `codex exec` fallback never subscribes)
499
+ // → fall through to the queue so the message runs as the next turn.
500
+ }
501
+ }
502
+ // /new must execute immediately after interrupting the active turn. If it
503
+ // joins the per-session queue, it waits behind the very turn it just
504
+ // aborted; a backend that is slow to close its stream can otherwise leave
505
+ // the command with no acknowledgement and no rotated session.
506
+ if (isLarkNewSessionCommand(prompt)) {
507
+ await this.handleNewSessionCommand(event, sessionKey);
508
+ return;
509
+ }
510
+ // Informational commands (/help, /models, /search) answer instantly and
511
+ // also bypass the queue so they respond even while a turn is running.
512
+ if (command && LARK_INFO_COMMANDS.has(command.name)) {
513
+ await this.handleInfoCommand(event, sessionKey, command);
514
+ return;
515
+ }
516
+ // Action commands (/goal, /diff) operate on the current session / working
517
+ // tree without running a Codex turn; also bypass the queue.
518
+ if (command && LARK_ACTION_COMMANDS.has(command.name)) {
519
+ await this.handleActionCommand(event, sessionKey, command);
520
+ return;
521
+ }
522
+ // /resume lists / navigates the user's recent conversations; bypasses queue.
523
+ if (command && command.name === "resume") {
524
+ await this.handleResumeCommand(event, sessionKey, command);
525
+ return;
526
+ }
527
+ // /fork spins the conversation off into a new group/thread; bypasses queue.
528
+ if (command && command.name === "fork") {
529
+ await this.handleForkCommand(event, sessionKey);
530
+ return;
531
+ }
532
+ // /cwd is retained only as a migration notice; Project owns workspace selection.
533
+ if (command && command.name === "cwd") {
534
+ await this.handleCwdCommand(event);
535
+ return;
536
+ }
537
+ // /runtime and /model open the interactive settings card; bypass queue.
538
+ if (command && (command.name === "runtime" || command.name === "model")) {
539
+ await this.handleSettingsCommand(event, sessionKey, command.name === "runtime");
540
+ return;
541
+ }
542
+ // /agent lists declarative agents or selects one for this conversation.
543
+ if (command && command.name === "agent") {
544
+ await this.handleAgentCommand(event, sessionKey, command);
545
+ return;
546
+ }
547
+ await this.queue.enqueue(sessionKey, async () => {
548
+ // Best-effort, off the turn's critical path: the directory only feeds
549
+ // /resume, so a lagging or failed write must never delay the reply.
550
+ void this.recordConversation(event, sessionKey, prompt);
551
+ const session = await this.activeSessionStore.ensure(sessionKey);
552
+ if (session.source !== "existing") {
553
+ this.logger.log({
554
+ event: "session.started",
555
+ tenant_key: event.tenant_key ?? event.sender.tenant_key,
556
+ chat_id: event.message.chat_id,
557
+ thread_id: event.message.thread_id,
558
+ message_id: event.message.message_id,
559
+ session_key: sessionKey,
560
+ session_id: session.sessionId,
561
+ provider: this.provider,
562
+ });
563
+ // A session title is initialized from its first ordinary message and
564
+ // remains stable for the rest of that session.
565
+ this.registerSession(session.sessionId, prompt);
566
+ }
567
+ this.logMessageReceived(event, sessionKey, session.sessionId);
568
+ const startedReactionId = await this.tryCreateReaction(event, sessionKey, LARK_WORK_STARTED_REACTION, session.sessionId);
569
+ const reply = await this.buildReply(event, sessionKey, session.sessionId, prompt, session.cwd ?? this.cwd, await this.resolveTurnExecution(sessionKey, session));
570
+ if (reply.text) {
571
+ const sent = await this.sendReplyText(event, reply.text);
572
+ if (sent) {
573
+ this.logger.log({
574
+ event: "message.completed",
575
+ tenant_key: event.tenant_key ?? event.sender.tenant_key,
576
+ chat_id: event.message.chat_id,
577
+ thread_id: event.message.thread_id,
578
+ message_id: event.message.message_id,
579
+ session_key: sessionKey,
580
+ session_id: session.sessionId,
581
+ provider: reply.provider,
582
+ model: reply.model,
583
+ });
584
+ }
585
+ }
586
+ await this.tryCreateReaction(event, sessionKey, LARK_WORK_COMPLETED_REACTION, session.sessionId);
587
+ await this.tryDeleteReaction(event, sessionKey, startedReactionId, LARK_WORK_STARTED_REACTION, session.sessionId);
588
+ });
589
+ }
590
+ async handleStopCommand(event, sessionKey) {
591
+ const aborted = this.interruptSession(sessionKey);
592
+ this.logger.log({
593
+ event: aborted ? "session.stopped" : "session.stop_noop",
594
+ tenant_key: event.tenant_key ?? event.sender.tenant_key,
595
+ chat_id: event.message.chat_id,
596
+ thread_id: event.message.thread_id,
597
+ message_id: event.message.message_id,
598
+ session_key: sessionKey,
599
+ provider: this.provider,
600
+ });
601
+ await this.sendReplyText(event, aborted ? DEFAULT_STOP_ACK_RESPONSE : DEFAULT_STOP_NO_INFLIGHT_RESPONSE);
602
+ }
603
+ async handleInfoCommand(event, sessionKey, command) {
604
+ const session = await this.activeSessionStore.get(sessionKey);
605
+ const runtime = session?.runtime ?? this.provider;
606
+ let text;
607
+ switch (command.name) {
608
+ case "models":
609
+ text = await this.renderModelsReply(runtime);
610
+ break;
611
+ case "help":
612
+ default:
613
+ text = LARK_HELP_TEXT;
614
+ break;
615
+ }
616
+ await this.sendReplyText(event, text);
617
+ this.logger.log({
618
+ event: "command.handled",
619
+ tenant_key: event.tenant_key ?? event.sender.tenant_key,
620
+ chat_id: event.message.chat_id,
621
+ thread_id: event.message.thread_id,
622
+ message_id: event.message.message_id,
623
+ session_key: sessionKey,
624
+ command: command.name,
625
+ provider: this.provider,
626
+ });
627
+ }
628
+ async handleActionCommand(event, sessionKey, command) {
629
+ if (command.name === "diff") {
630
+ await this.handleDiffCommand(event, sessionKey);
631
+ }
632
+ else {
633
+ const text = command.name === "goal"
634
+ ? await this.renderGoalReply(sessionKey, command.args)
635
+ : LARK_HELP_TEXT;
636
+ await this.sendReplyText(event, text);
637
+ }
638
+ this.logger.log({
639
+ event: "command.handled",
640
+ tenant_key: event.tenant_key ?? event.sender.tenant_key,
641
+ chat_id: event.message.chat_id,
642
+ thread_id: event.message.thread_id,
643
+ message_id: event.message.message_id,
644
+ session_key: sessionKey,
645
+ command: command.name,
646
+ provider: this.provider,
647
+ });
648
+ }
649
+ /**
650
+ * Render the working-tree diff as a CardKit card (summary + per-file stats +
651
+ * a collapsed full diff). Falls back to a plain-text reply when the sender
652
+ * has no card capability or the card send fails.
653
+ */
654
+ async handleDiffCommand(event, sessionKey) {
655
+ const result = await this.collectGitDiff();
656
+ if (result.kind === "error") {
657
+ await this.sendReplyText(event, `git diff 失败:${result.message}`);
658
+ return;
659
+ }
660
+ if (result.kind === "clean") {
661
+ await this.sendReplyText(event, "工作区干净,没有改动。");
662
+ return;
663
+ }
664
+ if (this.messageSender.createCard) {
665
+ try {
666
+ await this.sendStandaloneCard(event, buildDiffCardJson(result.input));
667
+ return;
668
+ }
669
+ catch (error) {
670
+ this.logger.log({
671
+ event: "command.diff_card_failed",
672
+ chat_id: event.message.chat_id,
673
+ thread_id: event.message.thread_id,
674
+ message_id: event.message.message_id,
675
+ session_key: sessionKey,
676
+ error: toErrorMessage(error),
677
+ });
678
+ }
679
+ }
680
+ await this.sendReplyText(event, result.fallbackText);
681
+ }
682
+ async collectGitDiff() {
683
+ const cwd = this.cwd ?? process.cwd();
684
+ try {
685
+ const [{ stdout: diff }, { stdout: numstat }, { stdout: untracked }] = await Promise.all([
686
+ execFileAsync("git", ["-C", cwd, "diff"], { maxBuffer: 8 * 1024 * 1024 }),
687
+ execFileAsync("git", ["-C", cwd, "diff", "--numstat"], { maxBuffer: 8 * 1024 * 1024 }),
688
+ execFileAsync("git", ["-C", cwd, "ls-files", "--others", "--exclude-standard"], {
689
+ maxBuffer: 8 * 1024 * 1024,
690
+ }),
691
+ ]);
692
+ const files = [];
693
+ let totalAdditions = 0;
694
+ let totalDeletions = 0;
695
+ for (const line of numstat.split("\n")) {
696
+ if (!line.trim()) {
697
+ continue;
698
+ }
699
+ const [addRaw, delRaw, ...rest] = line.split("\t");
700
+ const filePath = rest.join("\t").trim();
701
+ if (!filePath) {
702
+ continue;
703
+ }
704
+ const additions = addRaw === "-" ? null : Number(addRaw);
705
+ const deletions = delRaw === "-" ? null : Number(delRaw);
706
+ if (additions != null && Number.isFinite(additions)) {
707
+ totalAdditions += additions;
708
+ }
709
+ if (deletions != null && Number.isFinite(deletions)) {
710
+ totalDeletions += deletions;
711
+ }
712
+ files.push({ path: filePath, additions, deletions });
713
+ }
714
+ const untrackedFiles = untracked
715
+ .split("\n")
716
+ .map((file) => file.trim())
717
+ .filter(Boolean);
718
+ for (const filePath of untrackedFiles) {
719
+ files.push({ path: filePath, additions: null, deletions: null, untracked: true });
720
+ }
721
+ if (files.length === 0 && !diff.trim()) {
722
+ return { kind: "clean" };
723
+ }
724
+ const overCardLimit = Buffer.byteLength(diff, "utf8") > DIFF_CARD_MAX_BYTES;
725
+ const diffBody = overCardLimit ? diff.slice(0, DIFF_CARD_MAX_BYTES) : diff.trimEnd();
726
+ // Plain-text fallback mirrors the previous /diff output.
727
+ let body = diff.trimEnd();
728
+ if (untrackedFiles.length) {
729
+ const list = untrackedFiles.map((file) => `?? ${file}`).join("\n");
730
+ body = body ? `${body}\n\n未跟踪文件:\n${list}` : `未跟踪文件:\n${list}`;
731
+ }
732
+ const fallbackText = Buffer.byteLength(body, "utf8") > DIFF_REPLY_MAX_BYTES
733
+ ? `${body.slice(0, DIFF_REPLY_MAX_BYTES)}\n… (diff 过长已截断)`
734
+ : body;
735
+ return {
736
+ kind: "diff",
737
+ input: { files, totalAdditions, totalDeletions, diffBody, truncated: overCardLimit },
738
+ fallbackText,
739
+ };
740
+ }
741
+ catch (error) {
742
+ return { kind: "error", message: toErrorMessage(error) };
743
+ }
744
+ }
745
+ async renderGoalReply(sessionKey, args) {
746
+ if (!this.capabilities) {
747
+ return LARK_BACKEND_UNSUPPORTED;
748
+ }
749
+ const session = await this.activeSessionStore.get(sessionKey);
750
+ if (!session) {
751
+ return LARK_NO_ACTIVE_SESSION;
752
+ }
753
+ const sub = args.trim();
754
+ if (!sub) {
755
+ const result = await this.capabilities.getGoal(session.sessionId);
756
+ if (!result.ok) {
757
+ return this.capabilityErrorText(result);
758
+ }
759
+ const goal = result.data;
760
+ if (!goal?.objective) {
761
+ return "当前没有设置目标。用 `/goal <目标>` 设置。";
762
+ }
763
+ const status = goal.status ? `(${goal.status})` : "";
764
+ return `当前目标:${goal.objective}${status}`;
765
+ }
766
+ if (sub === "clear") {
767
+ const result = await this.capabilities.clearGoal(session.sessionId);
768
+ return result.ok ? "已清除当前会话目标。" : this.capabilityErrorText(result);
769
+ }
770
+ const result = await this.capabilities.setGoal(session.sessionId, sub);
771
+ return result.ok ? `已设置目标:${sub}` : this.capabilityErrorText(result);
772
+ }
773
+ capabilityErrorText(result) {
774
+ switch (result.reason) {
775
+ case "no_active_thread":
776
+ return LARK_NO_ACTIVE_SESSION;
777
+ case "unsupported":
778
+ return LARK_BACKEND_UNSUPPORTED;
779
+ default:
780
+ return `操作失败:${result.message ?? "未知错误"}`;
781
+ }
782
+ }
783
+ conversationKindOf(event) {
784
+ if (event.message.thread_id) {
785
+ return "thread";
786
+ }
787
+ return event.message.chat_type === "group" ? "group" : "p2p";
788
+ }
789
+ async recordConversation(event, sessionKey, prompt) {
790
+ const title = prompt.trim();
791
+ if (!title) {
792
+ return;
793
+ }
794
+ try {
795
+ await this.conversationDirectory.record({
796
+ conversationKey: sessionKey,
797
+ chatId: event.message.chat_id,
798
+ threadId: event.message.thread_id,
799
+ kind: this.conversationKindOf(event),
800
+ title,
801
+ participantOpenId: event.sender.sender_id?.open_id,
802
+ });
803
+ }
804
+ catch (error) {
805
+ this.logger.log({
806
+ event: "conversation.record_failed",
807
+ session_key: sessionKey,
808
+ message_id: event.message.message_id,
809
+ error: toErrorMessage(error),
810
+ });
811
+ }
812
+ }
813
+ async handleCwdCommand(event) {
814
+ await this.sendReplyText(event, "工作目录已改由 Rynx Project 管理;请在 Rynx 中创建或选择 Project。未选择时会使用该 Session 的独立临时目录。");
815
+ }
816
+ async handleResumeCommand(event, sessionKey, command) {
817
+ const arg = command.args.trim();
818
+ if (arg) {
819
+ await this.resumeByShortId(event, arg);
820
+ }
821
+ else {
822
+ await this.resumeList(event);
823
+ }
824
+ this.logger.log({
825
+ event: "command.handled",
826
+ tenant_key: event.tenant_key ?? event.sender.tenant_key,
827
+ chat_id: event.message.chat_id,
828
+ thread_id: event.message.thread_id,
829
+ message_id: event.message.message_id,
830
+ session_key: sessionKey,
831
+ command: "resume",
832
+ provider: this.provider,
833
+ });
834
+ }
835
+ async resumeList(event) {
836
+ const openId = event.sender.sender_id?.open_id;
837
+ if (!openId) {
838
+ await this.sendReplyText(event, "无法识别你的身份,暂时无法列出历史会话。");
839
+ return;
840
+ }
841
+ const records = await this.conversationDirectory.listForUser(openId, 5);
842
+ if (records.length === 0) {
843
+ await this.sendReplyText(event, "你还没有可恢复的历史会话。");
844
+ return;
845
+ }
846
+ if (this.canUseCardSender()) {
847
+ try {
848
+ await this.sendStandaloneCard(event, buildResumeCardJson(records, {
849
+ chatId: event.message.chat_id,
850
+ threadId: event.message.thread_id,
851
+ }));
852
+ return;
853
+ }
854
+ catch (error) {
855
+ this.logger.log({
856
+ event: "resume.card_failed",
857
+ message_id: event.message.message_id,
858
+ error: toErrorMessage(error),
859
+ });
860
+ // fall through to text
861
+ }
862
+ }
863
+ const lines = records.map((record, index) => {
864
+ if (record.kind === "thread") {
865
+ return `${index + 1}. ${record.title} \`${record.shortId}\`(话题,发送 /resume ${record.shortId} 转发到此)`;
866
+ }
867
+ return `${index + 1}. ${record.title} \`${record.shortId}\` ${buildChatApplink(record.chatId)}`;
868
+ });
869
+ await this.sendReplyText(event, ["最近会话:", ...lines].join("\n"));
870
+ }
871
+ async resumeByShortId(event, shortId) {
872
+ const record = await this.conversationDirectory.getByShortId(shortId);
873
+ if (!record) {
874
+ await this.sendReplyText(event, `没有找到 id 为 \`${shortId}\` 的会话。`);
875
+ return;
876
+ }
877
+ if (record.kind === "thread" && record.threadId) {
878
+ if (!this.messageSender.forwardThread) {
879
+ await this.sendReplyText(event, "当前不支持转发话题。");
880
+ return;
881
+ }
882
+ const target = this.forwardTargetOf(event);
883
+ try {
884
+ await this.messageSender.forwardThread({
885
+ threadId: record.threadId,
886
+ receiveId: target.receiveId,
887
+ receiveIdType: target.receiveIdType,
888
+ uuid: randomUUID(),
889
+ });
890
+ await this.sendReplyText(event, `已把话题「${record.title}」转发到当前会话。`);
891
+ }
892
+ catch (error) {
893
+ await this.sendReplyText(event, `转发失败:${toErrorMessage(error)}`);
894
+ }
895
+ return;
896
+ }
897
+ await this.sendReplyText(event, `会话「${record.title}」:${buildChatApplink(record.chatId)}`);
898
+ }
899
+ async sendStandaloneCard(event, cardJson) {
900
+ const sender = this.messageSender;
901
+ if (!sender.createCard) {
902
+ throw new Error("card sender unavailable");
903
+ }
904
+ const { cardId } = await sender.createCard({ cardJson });
905
+ const uuid = randomUUID();
906
+ if (event.message.thread_id && sender.replyInteractive) {
907
+ const { messageId } = await sender.replyInteractive({
908
+ messageId: event.message.message_id,
909
+ cardId,
910
+ uuid,
911
+ replyInThread: true,
912
+ });
913
+ return { cardId, messageId };
914
+ }
915
+ if (sender.sendInteractive) {
916
+ const { messageId } = await sender.sendInteractive({
917
+ chatId: event.message.chat_id,
918
+ cardId,
919
+ uuid,
920
+ });
921
+ return { cardId, messageId };
922
+ }
923
+ return { cardId };
924
+ }
925
+ async handleForkCommand(event, sessionKey) {
926
+ const sender = this.messageSender;
927
+ if (!sender.sendText ||
928
+ !sender.listMessages ||
929
+ !sender.listChatMembers) {
930
+ await this.sendReplyText(event, "当前不支持 /fork(仅 app_server 后端 + 飞书机器人能力)。");
931
+ return;
932
+ }
933
+ const session = await this.activeSessionStore.get(sessionKey);
934
+ if (!session) {
935
+ await this.sendReplyText(event, LARK_NO_ACTIVE_SESSION);
936
+ return;
937
+ }
938
+ const tenantKey = event.tenant_key ?? event.sender.tenant_key ?? "unknown-tenant";
939
+ const isThread = Boolean(event.message.thread_id);
940
+ const directoryRecord = await this.conversationDirectory.get(sessionKey);
941
+ // Prefer the real Feishu chat name; fall back to the last user message, then
942
+ // a sensible default. (p2p chats have no name, so the fallback applies.)
943
+ let chatName = "";
944
+ if (sender.getChat) {
945
+ try {
946
+ chatName = (await sender.getChat({ chatId: event.message.chat_id })).name;
947
+ }
948
+ catch (error) {
949
+ this.logger.log({ event: "fork.get_chat_failed", error: toErrorMessage(error), message_id: event.message.message_id });
950
+ }
951
+ }
952
+ const baseName = chatName.trim() || directoryRecord?.title?.trim() || "Codex 会话";
953
+ // Members serve both the transcript (id → name) and, for the group case,
954
+ // the new group's member list. Fetch once, best-effort.
955
+ let members = [];
956
+ try {
957
+ members = await sender.listChatMembers({ chatId: event.message.chat_id });
958
+ }
959
+ catch (error) {
960
+ this.logger.log({ event: "fork.members_failed", error: toErrorMessage(error), message_id: event.message.message_id });
961
+ }
962
+ const nameById = new Map(members.map((m) => [m.memberId, m.name]));
963
+ let transcript = "";
964
+ try {
965
+ const messages = await sender.listMessages({
966
+ containerIdType: isThread ? "thread" : "chat",
967
+ containerId: isThread ? event.message.thread_id : event.message.chat_id,
968
+ pageSize: 50,
969
+ });
970
+ transcript = buildForkTranscript(messages, nameById);
971
+ }
972
+ catch (error) {
973
+ this.logger.log({ event: "fork.transcript_failed", error: toErrorMessage(error), message_id: event.message.message_id });
974
+ }
975
+ const header = `📋 原会话「${baseName}」记录(fork):`;
976
+ const body = transcript ? `${header}\n${transcript}` : header;
977
+ if (!this.sessionProvisioner) {
978
+ await this.sendReplyText(event, LARK_BACKEND_UNSUPPORTED);
979
+ return;
980
+ }
981
+ let newLocalThreadId;
982
+ try {
983
+ newLocalThreadId = await this.sessionProvisioner({
984
+ sessionKey: `fork:${tenantKey}:${event.message.message_id}`,
985
+ fromSessionId: session.sessionId,
986
+ title: `${baseName} - fork`,
987
+ });
988
+ }
989
+ catch (error) {
990
+ await this.sendReplyText(event, `fork 会话创建失败:${toErrorMessage(error)}`);
991
+ return;
992
+ }
993
+ let newSessionKey;
994
+ let newChatId = null;
995
+ try {
996
+ if (isThread) {
997
+ const sent = await sender.sendText({
998
+ chatId: event.message.chat_id,
999
+ text: body,
1000
+ uuid: randomUUID(),
1001
+ });
1002
+ if (!sent.threadId) {
1003
+ await this.sendReplyText(event, "fork 失败:未能创建新话题。");
1004
+ return;
1005
+ }
1006
+ newSessionKey = `lark:${tenantKey}:thread:${sent.threadId}`;
1007
+ }
1008
+ else {
1009
+ if (!sender.createChat) {
1010
+ await this.sendReplyText(event, "当前不支持建群。");
1011
+ return;
1012
+ }
1013
+ const userIdList = members.map((m) => m.memberId).filter(Boolean);
1014
+ const created = await sender.createChat({
1015
+ name: `${baseName} - fork`,
1016
+ userIdList,
1017
+ uuid: randomUUID(),
1018
+ });
1019
+ await sender.sendText({ chatId: created.chatId, text: body, uuid: randomUUID() });
1020
+ newSessionKey = `lark:${tenantKey}:chat:${created.chatId}`;
1021
+ newChatId = created.chatId;
1022
+ }
1023
+ }
1024
+ catch (error) {
1025
+ await this.sendReplyText(event, `fork 失败:${toErrorMessage(error)}`);
1026
+ return;
1027
+ }
1028
+ // Bind the new external conversation to the already-committed Machine
1029
+ // Session fork. Feishu group/topic creation remains channel-owned.
1030
+ try {
1031
+ await this.activeSessionStore.set(newSessionKey, newLocalThreadId, session.cwd);
1032
+ }
1033
+ catch (error) {
1034
+ await this.sendReplyText(event, `fork 已创建,但飞书会话绑定失败:${toErrorMessage(error)}`);
1035
+ return;
1036
+ }
1037
+ const contextNote = "已带上当前会话上下文。";
1038
+ // Surface a one-tap entry into the new conversation in the current chat.
1039
+ if (newChatId) {
1040
+ const entryCardJson = buildOpenChatCardJson(newChatId, `${baseName} - fork`);
1041
+ let sentCard = false;
1042
+ if (this.canUseCardSender()) {
1043
+ try {
1044
+ await this.sendStandaloneCard(event, entryCardJson);
1045
+ sentCard = true;
1046
+ }
1047
+ catch (error) {
1048
+ this.logger.log({ event: "fork.entry_card_failed", error: toErrorMessage(error), message_id: event.message.message_id });
1049
+ }
1050
+ }
1051
+ if (!sentCard) {
1052
+ await this.sendReplyText(event, `已 fork 到新群「${baseName} - fork」,点此进入:${buildChatApplink(newChatId)}(${contextNote})`);
1053
+ }
1054
+ }
1055
+ else {
1056
+ // Topic thread: the root message is already visible in this group.
1057
+ await this.sendReplyText(event, `已在本群新建话题(见上方新消息),可在其中继续对话。${contextNote}`);
1058
+ }
1059
+ this.logger.log({
1060
+ event: "command.handled",
1061
+ tenant_key: tenantKey,
1062
+ chat_id: event.message.chat_id,
1063
+ thread_id: event.message.thread_id,
1064
+ message_id: event.message.message_id,
1065
+ session_key: sessionKey,
1066
+ command: "fork",
1067
+ provider: this.provider,
1068
+ });
1069
+ }
1070
+ /**
1071
+ * Send the `/runtime` (or `/model`) settings card and seed the pending
1072
+ * selection state with the conversation's current settings.
1073
+ */
1074
+ async handleSettingsCommand(event, sessionKey, includeRuntime) {
1075
+ const session = await this.activeSessionStore.get(sessionKey);
1076
+ const currentRuntime = session?.runtime ?? this.provider;
1077
+ const currentModel = session?.model;
1078
+ const currentEffort = session?.reasoningEffort;
1079
+ if (!this.canUseCardSender()) {
1080
+ await this.sendReplyText(event, this.renderSettingsSummary(currentRuntime, currentModel, currentEffort));
1081
+ return;
1082
+ }
1083
+ const models = await this.fetchModelOptions(currentRuntime);
1084
+ const pending = {
1085
+ includeRuntime,
1086
+ currentRuntime,
1087
+ currentModel,
1088
+ currentEffort,
1089
+ runtime: currentRuntime,
1090
+ model: currentModel ?? SETTINGS_DEFAULT_VALUE,
1091
+ effort: currentEffort ?? SETTINGS_DEFAULT_VALUE,
1092
+ models,
1093
+ };
1094
+ this.pendingSettings.set(sessionKey, pending);
1095
+ const cardJson = buildSettingsCardJson(this.settingsCardInput(sessionKey, pending));
1096
+ try {
1097
+ await this.sendStandaloneCard(event, cardJson);
1098
+ }
1099
+ catch (error) {
1100
+ this.pendingSettings.delete(sessionKey);
1101
+ await this.sendReplyText(event, `发送设置卡片失败:${toErrorMessage(error)}`);
1102
+ return;
1103
+ }
1104
+ this.logger.log({
1105
+ event: "command.handled",
1106
+ tenant_key: event.tenant_key ?? event.sender.tenant_key,
1107
+ chat_id: event.message.chat_id,
1108
+ thread_id: event.message.thread_id,
1109
+ message_id: event.message.message_id,
1110
+ session_key: sessionKey,
1111
+ command: includeRuntime ? "runtime" : "model",
1112
+ provider: currentRuntime,
1113
+ });
1114
+ }
1115
+ /**
1116
+ * `/agent` — list declarative agents, or select one for this conversation.
1117
+ * Selecting rotates to a fresh session (so sandbox/persona/tools don't change
1118
+ * mid-thread) and drops explicit runtime/model overrides so the agent drives them.
1119
+ */
1120
+ async handleAgentCommand(event, sessionKey, command) {
1121
+ const name = command.args.trim();
1122
+ const session = await this.activeSessionStore.get(sessionKey);
1123
+ const current = session?.agent;
1124
+ if (!name) {
1125
+ if (!this.agentCatalog) {
1126
+ await this.sendReplyText(event, "当前运行环境不支持 agent 目录。");
1127
+ return;
1128
+ }
1129
+ let specs;
1130
+ try {
1131
+ specs = await this.agentCatalog.list();
1132
+ }
1133
+ catch (error) {
1134
+ await this.sendReplyText(event, `读取 agent 失败:${toErrorMessage(error)}`);
1135
+ return;
1136
+ }
1137
+ const lines = ["可用 agent:"];
1138
+ if (specs.length === 0) {
1139
+ lines.push(" (暂无;用 `rynx agent add <名称>` 创建)");
1140
+ }
1141
+ else {
1142
+ for (const spec of specs) {
1143
+ const marker = spec.id === current ? "✓" : "·";
1144
+ const runtime = spec.runtime ?? "默认";
1145
+ const model = spec.model ?? "默认";
1146
+ const display = spec.name && spec.name !== spec.id ? `${spec.name} · ` : "";
1147
+ lines.push(` ${marker} ${spec.id}(${display}${runtime} / ${model})`);
1148
+ }
1149
+ }
1150
+ lines.push(current ? `\n当前:${current}` : "\n当前:未选择(用全局默认)");
1151
+ lines.push("用 `/agent <名称>` 切换。");
1152
+ await this.sendReplyText(event, lines.join("\n"));
1153
+ return;
1154
+ }
1155
+ if (!this.agentCatalog) {
1156
+ await this.sendReplyText(event, "当前运行环境不支持 agent 目录。");
1157
+ return;
1158
+ }
1159
+ let spec;
1160
+ try {
1161
+ spec = await this.agentCatalog.get(name);
1162
+ }
1163
+ catch (error) {
1164
+ await this.sendReplyText(event, `agent「${name}」配置无效:${toErrorMessage(error)}`);
1165
+ return;
1166
+ }
1167
+ if (!spec) {
1168
+ await this.sendReplyText(event, `未找到 agent「${name}」。用 /agent 查看可用列表。`);
1169
+ return;
1170
+ }
1171
+ await this.activeSessionStore.setAgent(sessionKey, name);
1172
+ const runtime = spec.runtime ?? "默认运行时";
1173
+ const model = spec.model ?? "默认模型";
1174
+ await this.sendReplyText(event, `已切换到 agent「${name}」(${runtime} / ${model}),并开启新会话。`);
1175
+ this.logger.log({
1176
+ event: "command.handled",
1177
+ tenant_key: event.tenant_key ?? event.sender.tenant_key,
1178
+ chat_id: event.message.chat_id,
1179
+ thread_id: event.message.thread_id,
1180
+ message_id: event.message.message_id,
1181
+ session_key: sessionKey,
1182
+ command: "agent",
1183
+ provider: spec.runtime ?? this.provider,
1184
+ });
1185
+ }
1186
+ /**
1187
+ * Label shown for the model in the turn card. An empty model means the
1188
+ * runtime follows its own configured default (e.g. traex with no
1189
+ * `TRAEX_MODEL`); show a placeholder so the status row isn't blank (the
1190
+ * runtime itself is shown separately).
1191
+ */
1192
+ displayModel(model) {
1193
+ return model?.trim() || "默认模型";
1194
+ }
1195
+ renderSettingsSummary(runtime, model, effort) {
1196
+ return [
1197
+ "当前会话设置:",
1198
+ `• 运行时:${getRuntimeProfile(runtime).displayName}`,
1199
+ `• 模型:${model ?? "(运行时默认)"}`,
1200
+ `• 思考强度:${effort ?? "(默认)"}`,
1201
+ "(当前环境不支持交互卡片,无法在此切换。)",
1202
+ ].join("\n");
1203
+ }
1204
+ /** Assemble the settings-card input from the current pending selection. */
1205
+ settingsCardInput(sessionKey, pending) {
1206
+ return {
1207
+ sessionKey,
1208
+ includeRuntime: pending.includeRuntime,
1209
+ runtimeOptions: Object.keys(RUNTIME_PROFILES).map((id) => ({
1210
+ value: id,
1211
+ label: RUNTIME_PROFILES[id].displayName,
1212
+ })),
1213
+ models: pending.models,
1214
+ selectedRuntime: isAgentRuntimeId(pending.runtime) ? pending.runtime : pending.currentRuntime,
1215
+ selectedModel: pending.model,
1216
+ selectedEffort: pending.effort,
1217
+ };
1218
+ }
1219
+ async fetchModelOptions(runtime) {
1220
+ if (!this.capabilities) {
1221
+ return [];
1222
+ }
1223
+ try {
1224
+ const response = await this.capabilities.listModels(runtime);
1225
+ const models = (response?.data ?? []).filter((model) => !model.hidden);
1226
+ return models.map((model) => ({
1227
+ value: String(model.model || model.id),
1228
+ label: String(model.displayName || model.model || model.id),
1229
+ isDefault: Boolean(model.isDefault),
1230
+ supportedEfforts: extractSupportedEfforts(model),
1231
+ }));
1232
+ }
1233
+ catch (error) {
1234
+ this.logger.log({ event: "settings.models_failed", error: toErrorMessage(error) });
1235
+ return [];
1236
+ }
1237
+ }
1238
+ /**
1239
+ * Handle a settings-card dropdown change. A runtime change re-scopes the
1240
+ * model list (and resets model/effort); a model change re-scopes the effort
1241
+ * list — both re-render the card in place via the callback response so the
1242
+ * dependent dropdowns update. Effort changes just update pending state.
1243
+ */
1244
+ async handleSettingsSelect(event) {
1245
+ const value = event.value;
1246
+ const sessionKey = typeof value?.sessionKey === "string" ? value.sessionKey : null;
1247
+ const field = typeof value?.field === "string" ? value.field : null;
1248
+ const option = typeof event.option === "string" ? event.option : null;
1249
+ if (!sessionKey || !field || option === null) {
1250
+ return {};
1251
+ }
1252
+ const pending = this.pendingSettings.get(sessionKey);
1253
+ if (!pending) {
1254
+ return {};
1255
+ }
1256
+ if (field === "runtime") {
1257
+ if (!isAgentRuntimeId(option) || option === pending.runtime) {
1258
+ return {};
1259
+ }
1260
+ pending.runtime = option;
1261
+ pending.models = await this.fetchModelOptions(option);
1262
+ // A model from the old runtime can't carry over → reset model + effort.
1263
+ pending.model = SETTINGS_DEFAULT_VALUE;
1264
+ pending.effort = SETTINGS_DEFAULT_VALUE;
1265
+ return this.settingsCardResponse(sessionKey, pending);
1266
+ }
1267
+ if (field === "model") {
1268
+ if (option === pending.model) {
1269
+ return {};
1270
+ }
1271
+ pending.model = option;
1272
+ // Available efforts depend on the model → reset the effort selection.
1273
+ pending.effort = SETTINGS_DEFAULT_VALUE;
1274
+ return this.settingsCardResponse(sessionKey, pending);
1275
+ }
1276
+ if (field === "effort") {
1277
+ pending.effort = option;
1278
+ }
1279
+ return {};
1280
+ }
1281
+ /** Card-action response that re-renders the open settings card in place. */
1282
+ settingsCardResponse(sessionKey, pending) {
1283
+ return {
1284
+ card: { type: "raw", data: buildSettingsCard(this.settingsCardInput(sessionKey, pending)) },
1285
+ };
1286
+ }
1287
+ /** Apply the pending settings-card selections to the conversation. */
1288
+ async handleSettingsApply(event) {
1289
+ const value = event.value;
1290
+ const sessionKey = typeof value?.sessionKey === "string" ? value.sessionKey : null;
1291
+ if (!sessionKey) {
1292
+ return { toast: { type: "error", content: "无法识别会话。" } };
1293
+ }
1294
+ const pending = this.pendingSettings.get(sessionKey);
1295
+ if (!pending) {
1296
+ return { toast: { type: "warning", content: "设置已过期,请重新发送 /runtime。" } };
1297
+ }
1298
+ // Pending state is the source of truth — the dropdowns re-render it on each
1299
+ // change, so the selected model is always valid for the selected runtime.
1300
+ const runtime = pending.includeRuntime && isAgentRuntimeId(pending.runtime)
1301
+ ? pending.runtime
1302
+ : pending.currentRuntime;
1303
+ const model = pending.model === SETTINGS_DEFAULT_VALUE ? undefined : pending.model;
1304
+ const selectedModel = pending.models.find((candidate) => candidate.value === pending.model);
1305
+ const allowedEfforts = selectedModel?.supportedEfforts?.length
1306
+ ? selectedModel.supportedEfforts
1307
+ : SETTINGS_EFFORT_VALUES;
1308
+ const effort = pending.effort !== SETTINGS_DEFAULT_VALUE && allowedEfforts.includes(pending.effort)
1309
+ ? pending.effort
1310
+ : undefined;
1311
+ // Execution is immutable once a Session is created. Any Provider, model, or
1312
+ // reasoning-effort change therefore starts a fresh Session snapshot.
1313
+ const rotate = runtime !== pending.currentRuntime ||
1314
+ model !== pending.currentModel ||
1315
+ effort !== pending.currentEffort;
1316
+ await this.activeSessionStore.applySettings(sessionKey, {
1317
+ runtime,
1318
+ model,
1319
+ reasoningEffort: effort,
1320
+ rotate,
1321
+ });
1322
+ this.pendingSettings.delete(sessionKey);
1323
+ const summary = [
1324
+ `运行时:${getRuntimeProfile(runtime).displayName}`,
1325
+ `模型:${model ?? "运行时默认"}`,
1326
+ `思考强度:${effort ?? "默认"}`,
1327
+ ].join(" · ");
1328
+ this.logger.log({
1329
+ event: "settings.applied",
1330
+ session_key: sessionKey,
1331
+ message_id: event.messageId,
1332
+ provider: runtime,
1333
+ model: model ?? undefined,
1334
+ reasoning_effort: effort ?? null,
1335
+ rotated: rotate,
1336
+ });
1337
+ const headline = rotate ? `已应用并开启新会话 — ${summary}` : `已更新 — ${summary}`;
1338
+ // Replace the card via the callback response (atomic — no flash/revert).
1339
+ return this.settingsResultCard(pending.includeRuntime, "green", `✅ ${headline}`);
1340
+ }
1341
+ /** Cancel an open settings card without changing anything. */
1342
+ handleSettingsCancel(event) {
1343
+ const value = event.value;
1344
+ const sessionKey = typeof value?.sessionKey === "string" ? value.sessionKey : null;
1345
+ if (!sessionKey) {
1346
+ return {};
1347
+ }
1348
+ const pending = this.pendingSettings.get(sessionKey);
1349
+ if (!pending) {
1350
+ return {};
1351
+ }
1352
+ const includeRuntime = pending.includeRuntime;
1353
+ this.pendingSettings.delete(sessionKey);
1354
+ return this.settingsResultCard(includeRuntime, "grey", "已取消,设置未改动。");
1355
+ }
1356
+ /**
1357
+ * Build a card-action response that swaps the settings card for a static
1358
+ * result card. Returning the card in the callback response replaces it
1359
+ * atomically; an out-of-band `card.update` would briefly show the result and
1360
+ * then revert to the form.
1361
+ */
1362
+ settingsResultCard(includeRuntime, template, content) {
1363
+ return {
1364
+ card: {
1365
+ type: "raw",
1366
+ data: buildSettingsResultCard({ includeRuntime, template, content }),
1367
+ },
1368
+ };
1369
+ }
1370
+ async renderModelsReply(runtime) {
1371
+ if (!this.capabilities) {
1372
+ return LARK_BACKEND_UNSUPPORTED;
1373
+ }
1374
+ try {
1375
+ const response = await this.capabilities.listModels(runtime);
1376
+ if (!response) {
1377
+ return LARK_BACKEND_UNSUPPORTED;
1378
+ }
1379
+ const models = (response.data ?? []).filter((model) => !model.hidden);
1380
+ if (models.length === 0) {
1381
+ return "未获取到可用模型。";
1382
+ }
1383
+ const lines = models.map((model) => {
1384
+ const label = model.displayName || model.model || model.id;
1385
+ return `• ${label}${model.isDefault ? " ⭐️" : ""}`;
1386
+ });
1387
+ return ["可用模型:", ...lines].join("\n");
1388
+ }
1389
+ catch (error) {
1390
+ return `获取模型列表失败:${toErrorMessage(error)}`;
1391
+ }
1392
+ }
1393
+ async handleNewSessionCommand(event, sessionKey) {
1394
+ const currentSession = await this.activeSessionStore.get(sessionKey);
1395
+ this.logMessageReceived(event, sessionKey, currentSession?.sessionId);
1396
+ const rotated = await this.activeSessionStore.rotate(sessionKey);
1397
+ this.logger.log({
1398
+ event: "session.rotated",
1399
+ tenant_key: event.tenant_key ?? event.sender.tenant_key,
1400
+ chat_id: event.message.chat_id,
1401
+ thread_id: event.message.thread_id,
1402
+ message_id: event.message.message_id,
1403
+ session_key: sessionKey,
1404
+ session_id: rotated.sessionId,
1405
+ previous_session_id: rotated.previousSessionId,
1406
+ provider: this.provider,
1407
+ });
1408
+ const sent = await this.sendReplyText(event, DEFAULT_NEW_SESSION_RESPONSE);
1409
+ if (sent) {
1410
+ this.logger.log({
1411
+ event: "message.completed",
1412
+ tenant_key: event.tenant_key ?? event.sender.tenant_key,
1413
+ chat_id: event.message.chat_id,
1414
+ thread_id: event.message.thread_id,
1415
+ message_id: event.message.message_id,
1416
+ session_key: sessionKey,
1417
+ session_id: rotated.sessionId,
1418
+ provider: this.provider,
1419
+ });
1420
+ }
1421
+ }
1422
+ logMessageReceived(event, sessionKey, sessionId) {
1423
+ this.logger.log({
1424
+ event: "message.received",
1425
+ tenant_key: event.tenant_key ?? event.sender.tenant_key,
1426
+ chat_id: event.message.chat_id,
1427
+ thread_id: event.message.thread_id,
1428
+ message_id: event.message.message_id,
1429
+ session_key: sessionKey,
1430
+ session_id: sessionId,
1431
+ provider: this.provider,
1432
+ });
1433
+ }
1434
+ async tryCreateReaction(event, sessionKey, emojiType, sessionId) {
1435
+ try {
1436
+ return await this.messageSender.createReaction({
1437
+ messageId: event.message.message_id,
1438
+ emojiType,
1439
+ });
1440
+ }
1441
+ catch (error) {
1442
+ this.logger.log({
1443
+ event: "message.reaction_failed",
1444
+ tenant_key: event.tenant_key ?? event.sender.tenant_key,
1445
+ chat_id: event.message.chat_id,
1446
+ thread_id: event.message.thread_id,
1447
+ message_id: event.message.message_id,
1448
+ session_key: sessionKey,
1449
+ session_id: sessionId,
1450
+ emoji_type: emojiType,
1451
+ error: toErrorMessage(error),
1452
+ });
1453
+ return undefined;
1454
+ }
1455
+ }
1456
+ async tryDeleteReaction(event, sessionKey, reactionId, emojiType, sessionId) {
1457
+ if (!reactionId) {
1458
+ return;
1459
+ }
1460
+ try {
1461
+ await this.messageSender.deleteReaction({
1462
+ messageId: event.message.message_id,
1463
+ reactionId,
1464
+ });
1465
+ }
1466
+ catch (error) {
1467
+ this.logger.log({
1468
+ event: "message.reaction_delete_failed",
1469
+ tenant_key: event.tenant_key ?? event.sender.tenant_key,
1470
+ chat_id: event.message.chat_id,
1471
+ thread_id: event.message.thread_id,
1472
+ message_id: event.message.message_id,
1473
+ session_key: sessionKey,
1474
+ session_id: sessionId,
1475
+ emoji_type: emojiType,
1476
+ error: toErrorMessage(error),
1477
+ });
1478
+ }
1479
+ }
1480
+ /**
1481
+ * If this turn replies to a Lark message card, fetch the parent card and wrap
1482
+ * the prompt so the agent receives the card content as context. Generic: it
1483
+ * forwards any interactive (card) parent and names no specific skill — the
1484
+ * agent decides how to act on the card. Returns null when the parent is not a
1485
+ * card (or cannot be fetched), so the caller falls back to the raw prompt.
1486
+ */
1487
+ async tryBuildCardReplyPrompt(event, sessionKey, sessionId, prompt) {
1488
+ const parentMessageId = event.message.parent_id?.trim();
1489
+ if (!parentMessageId || !this.messageSender.getMessage) {
1490
+ return null;
1491
+ }
1492
+ try {
1493
+ const parentMessage = await this.messageSender.getMessage({ messageId: parentMessageId });
1494
+ const content = parentMessage?.body?.content ?? parentMessage?.content;
1495
+ const parentContent = typeof content === "string" && content.trim() ? content : null;
1496
+ if (parentMessage?.msgType !== "interactive" || !parentContent) {
1497
+ return null;
1498
+ }
1499
+ this.logger.log({
1500
+ event: "message.card_reply_detected",
1501
+ tenant_key: event.tenant_key ?? event.sender.tenant_key,
1502
+ chat_id: event.message.chat_id,
1503
+ thread_id: event.message.thread_id,
1504
+ message_id: event.message.message_id,
1505
+ session_key: sessionKey,
1506
+ session_id: sessionId,
1507
+ provider: this.provider,
1508
+ });
1509
+ return buildLarkCardReplyPrompt({
1510
+ userPrompt: prompt,
1511
+ cardContent: parentContent,
1512
+ });
1513
+ }
1514
+ catch (error) {
1515
+ this.logger.log({
1516
+ event: "message.parent_lookup_failed",
1517
+ tenant_key: event.tenant_key ?? event.sender.tenant_key,
1518
+ chat_id: event.message.chat_id,
1519
+ thread_id: event.message.thread_id,
1520
+ message_id: event.message.message_id,
1521
+ session_key: sessionKey,
1522
+ session_id: sessionId,
1523
+ provider: this.provider,
1524
+ error: toErrorMessage(error),
1525
+ });
1526
+ return null;
1527
+ }
1528
+ }
1529
+ /**
1530
+ * Map a session to the {@link TurnSettings} a turn runs with. Already-frozen
1531
+ * sessions are resolved locally; first-turn defaults and named-agent lookup
1532
+ * are delegated to the capability-gated host and then persisted exactly once.
1533
+ */
1534
+ async resolveTurnExecution(sessionKey, session) {
1535
+ if (session.runtime) {
1536
+ return {
1537
+ provider: session.runtime,
1538
+ providerExplicit: true,
1539
+ model: session.model,
1540
+ reasoningEffort: session.reasoningEffort,
1541
+ agentName: session.agent,
1542
+ };
1543
+ }
1544
+ // Lightweight unit-test/custom embeddings may omit the host resolver.
1545
+ if (!this.agentCatalog) {
1546
+ return {
1547
+ provider: session.runtime ?? this.provider,
1548
+ providerExplicit: session.runtime != null,
1549
+ model: session.model,
1550
+ reasoningEffort: session.reasoningEffort,
1551
+ agentName: session.agent ?? this.defaultAgent,
1552
+ };
1553
+ }
1554
+ const plan = await this.agentCatalog.resolveExecution({
1555
+ ...(this.defaultAgent ? { defaultAgent: this.defaultAgent } : {}),
1556
+ session: {
1557
+ ...(session.runtime ? { runtime: session.runtime } : {}),
1558
+ ...(session.model ? { model: session.model } : {}),
1559
+ ...(session.reasoningEffort ? { reasoningEffort: session.reasoningEffort } : {}),
1560
+ ...(session.agent ? { agent: session.agent } : {}),
1561
+ },
1562
+ });
1563
+ // Freshly resolved (not yet frozen) → persist it so it can't drift later.
1564
+ if (!plan.alreadyFrozen) {
1565
+ await this.activeSessionStore.freeze(sessionKey, {
1566
+ runtime: plan.runtime,
1567
+ model: plan.model,
1568
+ reasoningEffort: plan.reasoningEffort,
1569
+ agent: plan.agent,
1570
+ });
1571
+ }
1572
+ return {
1573
+ provider: plan.runtime,
1574
+ providerExplicit: true,
1575
+ model: plan.model,
1576
+ reasoningEffort: plan.reasoningEffort,
1577
+ agentName: plan.agent,
1578
+ };
1579
+ }
1580
+ async buildReply(event, sessionKey, sessionId, prompt, cwd, settings) {
1581
+ if (!["text", "post"].includes(event.message.message_type)) {
1582
+ return {
1583
+ text: DEFAULT_UNSUPPORTED_MESSAGE,
1584
+ provider: settings.provider,
1585
+ };
1586
+ }
1587
+ const runtimePrompt = (await this.tryBuildCardReplyPrompt(event, sessionKey, sessionId, prompt)) ?? prompt;
1588
+ if (!runtimePrompt) {
1589
+ return {
1590
+ text: DEFAULT_EMPTY_MESSAGE,
1591
+ provider: settings.provider,
1592
+ };
1593
+ }
1594
+ const turn = this.registerInFlight(sessionKey);
1595
+ try {
1596
+ if (this.canUseCardSender()) {
1597
+ const streamed = await this.tryBuildReplyWithCard(event, sessionKey, sessionId, runtimePrompt, cwd, turn, settings);
1598
+ if (streamed) {
1599
+ return streamed;
1600
+ }
1601
+ }
1602
+ return await this.buildReplyDrain(event, sessionKey, sessionId, runtimePrompt, cwd, turn, settings);
1603
+ }
1604
+ finally {
1605
+ // Claim the bridge's own rollout lines before releasing the in-flight
1606
+ // gate, so the CLI mirror's cursor sits past them and they're not
1607
+ // mirrored back into Feishu (they were already shown live).
1608
+ if (this.mirror && turn.codexSessionId) {
1609
+ try {
1610
+ await this.mirror.claimToEof(turn.codexSessionId);
1611
+ }
1612
+ catch (error) {
1613
+ this.logger.log({
1614
+ event: "mirror.claim_failed",
1615
+ session_key: sessionKey,
1616
+ session_id: sessionId,
1617
+ error: toErrorMessage(error),
1618
+ });
1619
+ }
1620
+ }
1621
+ this.clearInFlight(sessionKey, turn);
1622
+ }
1623
+ }
1624
+ canUseCardSender() {
1625
+ return Boolean(this.messageSender.createCard &&
1626
+ this.messageSender.batchUpdateCard &&
1627
+ (this.messageSender.sendInteractive || this.messageSender.replyInteractive));
1628
+ }
1629
+ async tryBuildReplyWithCard(event, sessionKey, sessionId, runtimePrompt, cwd, turn, settings) {
1630
+ let started;
1631
+ try {
1632
+ started = await this.conversationRuntime.runLive({
1633
+ message: runtimePrompt,
1634
+ threadId: sessionId,
1635
+ // When an agent is selected but the user hasn't pinned a runtime, pass
1636
+ // undefined so the agent spec's `executor.runtime` decides the backend.
1637
+ provider: settings.providerExplicit || !settings.agentName ? settings.provider : undefined,
1638
+ model: settings.model,
1639
+ reasoningEffort: settings.reasoningEffort,
1640
+ agentName: settings.agentName,
1641
+ cwd,
1642
+ signal: turn.abort.signal,
1643
+ steer: turn.steer,
1644
+ });
1645
+ }
1646
+ catch (error) {
1647
+ const errorMessage = toErrorMessage(error);
1648
+ this.logger.log({
1649
+ event: "message.failed",
1650
+ tenant_key: event.tenant_key ?? event.sender.tenant_key,
1651
+ chat_id: event.message.chat_id,
1652
+ thread_id: event.message.thread_id,
1653
+ message_id: event.message.message_id,
1654
+ session_key: sessionKey,
1655
+ session_id: sessionId,
1656
+ provider: settings.provider,
1657
+ error: errorMessage,
1658
+ });
1659
+ return {
1660
+ text: `处理失败:${errorMessage}`,
1661
+ provider: settings.provider,
1662
+ };
1663
+ }
1664
+ const orchestrator = new LarkSessionCardController({
1665
+ sender: this.messageSender,
1666
+ session: {
1667
+ chatId: event.message.chat_id,
1668
+ messageId: event.message.message_id,
1669
+ threadId: event.message.thread_id,
1670
+ },
1671
+ meta: {
1672
+ runtime: getRuntimeProfile(started.provider).shortLabel,
1673
+ model: this.displayModel(started.model),
1674
+ effort: settings.reasoningEffort ?? null,
1675
+ // Same stable id the conversation directory keys on, so the footer id is
1676
+ // exactly what `/resume <id>` accepts (conversationKey === sessionKey).
1677
+ resumeShortId: deriveConversationShortId(sessionKey),
1678
+ },
1679
+ throttleMs: this.cardThrottleMs,
1680
+ logger: { log: (entry) => this.logger.log({ event: "card.update", ...entry }) },
1681
+ stopButton: { sessionKey },
1682
+ onRuntimeSession: (runtimeSessionId) => this.noteRuntimeSession(turn, runtimeSessionId),
1683
+ });
1684
+ try {
1685
+ // Live co-drive single-writer: `runLive` yields THIS turn's canonical events
1686
+ // off the session bus (the forwarder mirrors them). The server's `onMirror`
1687
+ // already persisted them — do NOT persist again here (that was the double
1688
+ // write). Only render the card. Lark shows the user message inline from the
1689
+ // chat, so keep `session.input.consumed` out of the card render.
1690
+ for await (const sessionEvent of started.generator) {
1691
+ if (sessionEvent.type !== "session.input.consumed") {
1692
+ await orchestrator.ingest(sessionEvent);
1693
+ }
1694
+ }
1695
+ await orchestrator.finalizeAll("completed");
1696
+ }
1697
+ catch (error) {
1698
+ const errorMessage = toErrorMessage(error);
1699
+ const cancelled = error instanceof CodexRuntimeError && error.code === "codex_cancelled";
1700
+ this.logger.log({
1701
+ event: cancelled ? "message.cancelled" : "message.failed",
1702
+ tenant_key: event.tenant_key ?? event.sender.tenant_key,
1703
+ chat_id: event.message.chat_id,
1704
+ thread_id: event.message.thread_id,
1705
+ message_id: event.message.message_id,
1706
+ session_key: sessionKey,
1707
+ session_id: sessionId,
1708
+ provider: this.provider,
1709
+ error: errorMessage,
1710
+ });
1711
+ // The normalizer already emitted `response.failed` (so the card shows the
1712
+ // error); this finalizeAll is a safety net for any still-open card.
1713
+ await orchestrator.finalizeAll(cancelled ? "cancelled" : "failed");
1714
+ return {
1715
+ // A user-triggered cancel needs no redundant chat reply (the card carries it).
1716
+ text: cancelled ? "" : `处理失败:${errorMessage}`,
1717
+ provider: started.provider,
1718
+ model: started.model,
1719
+ };
1720
+ }
1721
+ // The card carries the answer; only emit a plain-text reply when no card
1722
+ // could be posted (e.g. the card API failed), so the chat isn't left empty.
1723
+ return {
1724
+ text: orchestrator.cardStarted ? "" : orchestrator.fallbackText.trim() || DEFAULT_EMPTY_RESPONSE,
1725
+ provider: started.provider,
1726
+ model: started.model,
1727
+ };
1728
+ }
1729
+ /**
1730
+ * On the turn's first `session.status` carrying a runtime session id, bind it
1731
+ * to the in-flight turn and register it with the CLI mirror (so the mirror
1732
+ * watches that rollout file, and the bridge's own lines are later claimed).
1733
+ */
1734
+ noteRuntimeSession(turn, runtimeSessionId) {
1735
+ if (turn.codexSessionId || !runtimeSessionId) {
1736
+ return;
1737
+ }
1738
+ turn.codexSessionId = runtimeSessionId;
1739
+ if (this.mirror) {
1740
+ void this.mirror.noteBoundSession(runtimeSessionId).catch((error) => {
1741
+ this.logger.log({
1742
+ event: "mirror.note_failed",
1743
+ session_key: runtimeSessionId,
1744
+ error: toErrorMessage(error),
1745
+ });
1746
+ });
1747
+ }
1748
+ }
1749
+ async buildReplyDrain(event, sessionKey, sessionId, runtimePrompt, cwd, turn, settings) {
1750
+ try {
1751
+ // Live co-drive: inject the turn and collect the reply text from the turn's
1752
+ // mirrored bus events (the server's onMirror persists them — we only read).
1753
+ const started = await this.conversationRuntime.runLive({
1754
+ message: runtimePrompt,
1755
+ threadId: sessionId,
1756
+ provider: settings.providerExplicit || !settings.agentName ? settings.provider : undefined,
1757
+ model: settings.model,
1758
+ reasoningEffort: settings.reasoningEffort,
1759
+ agentName: settings.agentName,
1760
+ cwd,
1761
+ signal: turn.abort.signal,
1762
+ steer: turn.steer,
1763
+ });
1764
+ const answer = await collectLiveRun(started.generator);
1765
+ return {
1766
+ text: answer.trim() || DEFAULT_EMPTY_RESPONSE,
1767
+ provider: started.provider,
1768
+ model: started.model,
1769
+ };
1770
+ }
1771
+ catch (error) {
1772
+ const errorMessage = toErrorMessage(error);
1773
+ this.logger.log({
1774
+ event: "message.failed",
1775
+ tenant_key: event.tenant_key ?? event.sender.tenant_key,
1776
+ chat_id: event.message.chat_id,
1777
+ thread_id: event.message.thread_id,
1778
+ message_id: event.message.message_id,
1779
+ session_key: sessionKey,
1780
+ session_id: sessionId,
1781
+ provider: settings.provider,
1782
+ error: errorMessage,
1783
+ });
1784
+ return {
1785
+ text: `处理失败:${errorMessage}`,
1786
+ provider: settings.provider,
1787
+ };
1788
+ }
1789
+ }
1790
+ async sendReplyText(event, text) {
1791
+ const chunks = chunkLarkReplyText(text);
1792
+ if (chunks.length === 0) {
1793
+ return false;
1794
+ }
1795
+ for (const [index, chunk] of chunks.entries()) {
1796
+ const uuid = `lark-reply:${event.message.message_id}:${index}`;
1797
+ if (event.message.thread_id) {
1798
+ await this.messageSender.replyText({
1799
+ messageId: event.message.message_id,
1800
+ text: chunk,
1801
+ uuid,
1802
+ replyInThread: true,
1803
+ });
1804
+ }
1805
+ else {
1806
+ await this.messageSender.createText({
1807
+ chatId: event.message.chat_id,
1808
+ text: chunk,
1809
+ uuid,
1810
+ });
1811
+ }
1812
+ }
1813
+ return true;
1814
+ }
1815
+ }
1816
+ /** Per-instance option readers: parse raw `channels.<id>.options` values
1817
+ * (strings from JSON) into the typed shapes the ingestor expects; `undefined`
1818
+ * ⇒ "not set for this instance" so the caller falls back to global config. */
1819
+ function larkStringOption(options, key) {
1820
+ const value = options?.[key];
1821
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
1822
+ }
1823
+ function larkBoolOption(options, key) {
1824
+ const value = options?.[key];
1825
+ if (typeof value === "boolean")
1826
+ return value;
1827
+ if (typeof value === "string" && value.trim())
1828
+ return value.trim().toLowerCase() === "true";
1829
+ return undefined;
1830
+ }
1831
+ function larkListOption(options, key) {
1832
+ const value = options?.[key];
1833
+ if (Array.isArray(value))
1834
+ return value.map(String).map((entry) => entry.trim()).filter(Boolean);
1835
+ if (typeof value === "string" && value.trim()) {
1836
+ return value.split(",").map((entry) => entry.trim()).filter(Boolean);
1837
+ }
1838
+ return undefined;
1839
+ }
1840
+ export class LocalLarkBotRuntime {
1841
+ clientProvider;
1842
+ messageIngestor;
1843
+ logger;
1844
+ connected = false;
1845
+ lastEventAt = null;
1846
+ lastError = null;
1847
+ constructor({ executionDefaults, agentCatalog, conversationRuntime, options, defaultAgent, logger = defaultLogger, clientProvider = new LarkClientProvider(options, larkSdkLogger(logger)), messageIngestor, capabilities, activeSessionStore: injectedActiveSessionStore, conversationDirectory: injectedConversationDirectory, sessionTitleWriter, sessionProvisioner, }) {
1848
+ this.clientProvider = clientProvider;
1849
+ this.logger = logger;
1850
+ if (messageIngestor) {
1851
+ this.messageIngestor = messageIngestor;
1852
+ return;
1853
+ }
1854
+ if (!clientProvider) {
1855
+ return;
1856
+ }
1857
+ if (!injectedConversationDirectory || !injectedActiveSessionStore) {
1858
+ throw new Error("LocalLarkBotRuntime requires explicit plugin-scoped state stores");
1859
+ }
1860
+ const conversationDirectory = injectedConversationDirectory;
1861
+ // The conversation → current-session routing map is plugin-owned state.
1862
+ const activeSessionStore = injectedActiveSessionStore;
1863
+ const messageSender = clientProvider.getMessageSender();
1864
+ const larkCwd = larkStringOption(options, "LARK_CWD");
1865
+ if (!executionDefaults || !agentCatalog) {
1866
+ throw new Error("LocalLarkBotRuntime requires host execution defaults and an agent catalog adapter");
1867
+ }
1868
+ const allowedRoots = [...new Set([
1869
+ ...executionDefaults.allowedRoots.map((root) => path.resolve(root)),
1870
+ ...(larkCwd ? [path.resolve(larkCwd)] : []),
1871
+ ])];
1872
+ const ingestor = new LarkMessageIngestor({
1873
+ conversationRuntime,
1874
+ messageSender,
1875
+ cwd: larkCwd,
1876
+ defaultRuntime: executionDefaults.runtime,
1877
+ defaultAgent: defaultAgent ?? executionDefaults.defaultAgent,
1878
+ agentCatalog,
1879
+ activeSessionStore,
1880
+ conversationDirectory,
1881
+ logger,
1882
+ capabilities,
1883
+ allowedRoots,
1884
+ disableP2p: larkBoolOption(options, "LARK_DISABLE_P2P") ?? false,
1885
+ groupAllowlistEnabled: larkBoolOption(options, "LARK_GROUP_ALLOWLIST_ENABLED") ?? false,
1886
+ groupAllowlist: larkListOption(options, "LARK_GROUP_ALLOWLIST") ?? [],
1887
+ sessionTitleWriter,
1888
+ sessionProvisioner,
1889
+ });
1890
+ this.messageIngestor = ingestor;
1891
+ }
1892
+ getStatus() {
1893
+ return {
1894
+ connected: this.connected,
1895
+ mode: "ws",
1896
+ last_event_at: this.lastEventAt,
1897
+ last_error: this.lastError,
1898
+ };
1899
+ }
1900
+ async start() {
1901
+ if (!this.clientProvider || !this.messageIngestor) {
1902
+ return;
1903
+ }
1904
+ // Resolve our own open_id before connecting so group `@`-mention routing is
1905
+ // in effect for the very first message. Best-effort: on failure the
1906
+ // ingestor keeps its legacy "any mention triggers" behavior.
1907
+ if (this.clientProvider.getBotOpenId) {
1908
+ const botOpenId = await this.clientProvider.getBotOpenId().catch(() => null);
1909
+ this.messageIngestor.setBotOpenId(botOpenId);
1910
+ this.logger.log({
1911
+ event: botOpenId ? "runtime.bot_identity_resolved" : "runtime.bot_identity_unresolved",
1912
+ });
1913
+ }
1914
+ try {
1915
+ await this.clientProvider.start({
1916
+ onMessage: (event) => {
1917
+ this.lastEventAt = new Date().toISOString();
1918
+ void this.messageIngestor?.ingest(event).catch((error) => {
1919
+ this.lastError = toErrorMessage(error);
1920
+ this.logger.log({
1921
+ event: "runtime.ingest_failed",
1922
+ error: this.lastError,
1923
+ tenant_key: event.tenant_key ?? event.sender.tenant_key,
1924
+ chat_id: event.message.chat_id,
1925
+ thread_id: event.message.thread_id,
1926
+ message_id: event.message.message_id,
1927
+ });
1928
+ });
1929
+ },
1930
+ onCardAction: async (cardEvent) => {
1931
+ this.lastEventAt = new Date().toISOString();
1932
+ try {
1933
+ return await this.messageIngestor.ingestCardAction(cardEvent);
1934
+ }
1935
+ catch (error) {
1936
+ this.lastError = toErrorMessage(error);
1937
+ this.logger.log({
1938
+ event: "runtime.card_action_failed",
1939
+ error: this.lastError,
1940
+ message_id: cardEvent.messageId,
1941
+ });
1942
+ return {
1943
+ toast: {
1944
+ type: "error",
1945
+ content: `卡片操作失败:${this.lastError}`,
1946
+ },
1947
+ };
1948
+ }
1949
+ },
1950
+ });
1951
+ this.connected = true;
1952
+ this.lastError = null;
1953
+ this.logger.log({
1954
+ event: "runtime.connected",
1955
+ });
1956
+ }
1957
+ catch (error) {
1958
+ this.connected = false;
1959
+ this.lastError = toErrorMessage(error);
1960
+ this.logger.log({
1961
+ event: "runtime.connection_failed",
1962
+ error: this.lastError,
1963
+ });
1964
+ throw error;
1965
+ }
1966
+ }
1967
+ async stop() {
1968
+ if (!this.clientProvider) {
1969
+ return;
1970
+ }
1971
+ await this.clientProvider.stop();
1972
+ this.connected = false;
1973
+ this.logger.log({
1974
+ event: "runtime.disconnected",
1975
+ });
1976
+ }
1977
+ }