@bubblebrain-ai/bubble 0.0.9 → 0.0.11

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 (153) hide show
  1. package/dist/agent.d.ts +1 -0
  2. package/dist/agent.js +5 -0
  3. package/dist/cli.d.ts +10 -0
  4. package/dist/cli.js +31 -3
  5. package/dist/feedback/collect.d.ts +7 -0
  6. package/dist/feedback/collect.js +119 -0
  7. package/dist/feedback/config.d.ts +14 -0
  8. package/dist/feedback/config.js +16 -0
  9. package/dist/feedback/redact.d.ts +1 -0
  10. package/dist/feedback/redact.js +25 -0
  11. package/dist/feedback/submit.d.ts +6 -0
  12. package/dist/feedback/submit.js +43 -0
  13. package/dist/feedback/types.d.ts +22 -0
  14. package/dist/feishu/agent-host/approval-card.d.ts +11 -0
  15. package/dist/feishu/agent-host/approval-card.js +46 -0
  16. package/dist/feishu/agent-host/approval-ui.d.ts +59 -0
  17. package/dist/feishu/agent-host/approval-ui.js +214 -0
  18. package/dist/feishu/agent-host/run-driver.d.ts +51 -0
  19. package/dist/feishu/agent-host/run-driver.js +295 -0
  20. package/dist/feishu/agent-host/runtime-deps.d.ts +33 -0
  21. package/dist/feishu/agent-host/runtime-deps.js +8 -0
  22. package/dist/feishu/card/budget.d.ts +40 -0
  23. package/dist/feishu/card/budget.js +134 -0
  24. package/dist/feishu/card/renderer.d.ts +29 -0
  25. package/dist/feishu/card/renderer.js +245 -0
  26. package/dist/feishu/card/run-state-types.d.ts +49 -0
  27. package/dist/feishu/card/run-state-types.js +15 -0
  28. package/dist/feishu/card/run-state.d.ts +21 -0
  29. package/dist/feishu/card/run-state.js +217 -0
  30. package/dist/feishu/channel/channel.d.ts +52 -0
  31. package/dist/feishu/channel/channel.js +74 -0
  32. package/dist/feishu/config.d.ts +24 -0
  33. package/dist/feishu/config.js +97 -0
  34. package/dist/feishu/format.d.ts +6 -0
  35. package/dist/feishu/format.js +14 -0
  36. package/dist/feishu/index.d.ts +4 -0
  37. package/dist/feishu/index.js +4 -0
  38. package/dist/feishu/logger.d.ts +31 -0
  39. package/dist/feishu/logger.js +62 -0
  40. package/dist/feishu/paths.d.ts +12 -0
  41. package/dist/feishu/paths.js +38 -0
  42. package/dist/feishu/process-registry.d.ts +29 -0
  43. package/dist/feishu/process-registry.js +90 -0
  44. package/dist/feishu/router/commands.d.ts +38 -0
  45. package/dist/feishu/router/commands.js +285 -0
  46. package/dist/feishu/router/event-router.d.ts +40 -0
  47. package/dist/feishu/router/event-router.js +208 -0
  48. package/dist/feishu/router/whitelist.d.ts +23 -0
  49. package/dist/feishu/router/whitelist.js +20 -0
  50. package/dist/feishu/runtime/active-runs.d.ts +32 -0
  51. package/dist/feishu/runtime/active-runs.js +84 -0
  52. package/dist/feishu/runtime/pending-queue.d.ts +36 -0
  53. package/dist/feishu/runtime/pending-queue.js +98 -0
  54. package/dist/feishu/runtime/process-pool.d.ts +29 -0
  55. package/dist/feishu/runtime/process-pool.js +49 -0
  56. package/dist/feishu/schema.d.ts +17 -0
  57. package/dist/feishu/schema.js +252 -0
  58. package/dist/feishu/scope/scope-registry.d.ts +39 -0
  59. package/dist/feishu/scope/scope-registry.js +148 -0
  60. package/dist/feishu/scope/session-binder.d.ts +44 -0
  61. package/dist/feishu/scope/session-binder.js +100 -0
  62. package/dist/feishu/scope/session-store.d.ts +24 -0
  63. package/dist/feishu/scope/session-store.js +73 -0
  64. package/dist/feishu/secrets.d.ts +37 -0
  65. package/dist/feishu/secrets.js +129 -0
  66. package/dist/feishu/serve.d.ts +12 -0
  67. package/dist/feishu/serve.js +288 -0
  68. package/dist/feishu/types.d.ts +75 -0
  69. package/dist/feishu/types.js +23 -0
  70. package/dist/feishu/wizard.d.ts +24 -0
  71. package/dist/feishu/wizard.js +121 -0
  72. package/dist/main.js +78 -29
  73. package/dist/model-catalog.js +3 -0
  74. package/dist/session.d.ts +11 -0
  75. package/dist/session.js +88 -2
  76. package/dist/slash-commands/commands.js +13 -0
  77. package/dist/slash-commands/feishu.d.ts +17 -0
  78. package/dist/slash-commands/feishu.js +400 -0
  79. package/dist/slash-commands/types.d.ts +3 -1
  80. package/dist/tui-ink/app.js +218 -60
  81. package/dist/tui-ink/code-highlight.js +2 -3
  82. package/dist/tui-ink/detect-theme.d.ts +1 -18
  83. package/dist/tui-ink/detect-theme.js +1 -37
  84. package/dist/tui-ink/display-history.d.ts +20 -3
  85. package/dist/tui-ink/display-history.js +26 -27
  86. package/dist/tui-ink/feedback-dialog.d.ts +19 -0
  87. package/dist/tui-ink/feedback-dialog.js +123 -0
  88. package/dist/tui-ink/feishu-setup-picker.d.ts +5 -0
  89. package/dist/tui-ink/feishu-setup-picker.js +261 -0
  90. package/dist/tui-ink/input-box.d.ts +3 -0
  91. package/dist/tui-ink/input-box.js +27 -0
  92. package/dist/tui-ink/input-history.js +3 -5
  93. package/dist/tui-ink/markdown.d.ts +32 -0
  94. package/dist/tui-ink/markdown.js +111 -4
  95. package/dist/tui-ink/message-list.d.ts +1 -6
  96. package/dist/tui-ink/message-list.js +85 -34
  97. package/dist/tui-ink/model-picker.js +1 -4
  98. package/dist/tui-ink/run-session-picker.d.ts +10 -0
  99. package/dist/tui-ink/run-session-picker.js +22 -0
  100. package/dist/tui-ink/run.js +7 -2
  101. package/dist/tui-ink/session-picker.d.ts +10 -0
  102. package/dist/tui-ink/session-picker.js +112 -0
  103. package/dist/tui-ink/terminal-mouse.d.ts +4 -0
  104. package/dist/tui-ink/terminal-mouse.js +23 -0
  105. package/dist/tui-ink/trace-groups.js +25 -2
  106. package/dist/tui-ink/welcome.js +2 -4
  107. package/package.json +4 -5
  108. package/dist/tui/clipboard.d.ts +0 -1
  109. package/dist/tui/clipboard.js +0 -53
  110. package/dist/tui/display-history.d.ts +0 -44
  111. package/dist/tui/display-history.js +0 -243
  112. package/dist/tui/escape-confirmation.d.ts +0 -15
  113. package/dist/tui/escape-confirmation.js +0 -30
  114. package/dist/tui/file-mentions.d.ts +0 -29
  115. package/dist/tui/file-mentions.js +0 -174
  116. package/dist/tui/global-key-router.d.ts +0 -3
  117. package/dist/tui/global-key-router.js +0 -87
  118. package/dist/tui/image-paste.d.ts +0 -95
  119. package/dist/tui/image-paste.js +0 -505
  120. package/dist/tui/markdown-inline.d.ts +0 -22
  121. package/dist/tui/markdown-inline.js +0 -68
  122. package/dist/tui/markdown-theme-rules.d.ts +0 -23
  123. package/dist/tui/markdown-theme-rules.js +0 -164
  124. package/dist/tui/markdown-theme.d.ts +0 -5
  125. package/dist/tui/markdown-theme.js +0 -27
  126. package/dist/tui/opencode-spinner.d.ts +0 -21
  127. package/dist/tui/opencode-spinner.js +0 -216
  128. package/dist/tui/prompt-keybindings.d.ts +0 -42
  129. package/dist/tui/prompt-keybindings.js +0 -35
  130. package/dist/tui/recent-activity.d.ts +0 -8
  131. package/dist/tui/recent-activity.js +0 -71
  132. package/dist/tui/render-signature.d.ts +0 -1
  133. package/dist/tui/render-signature.js +0 -7
  134. package/dist/tui/run.d.ts +0 -38
  135. package/dist/tui/run.js +0 -6996
  136. package/dist/tui/sidebar-mcp.d.ts +0 -31
  137. package/dist/tui/sidebar-mcp.js +0 -62
  138. package/dist/tui/sidebar-state.d.ts +0 -12
  139. package/dist/tui/sidebar-state.js +0 -69
  140. package/dist/tui/streaming-tool-args.d.ts +0 -15
  141. package/dist/tui/streaming-tool-args.js +0 -30
  142. package/dist/tui/tool-renderers/fallback.d.ts +0 -2
  143. package/dist/tui/tool-renderers/fallback.js +0 -75
  144. package/dist/tui/tool-renderers/registry.d.ts +0 -3
  145. package/dist/tui/tool-renderers/registry.js +0 -11
  146. package/dist/tui/tool-renderers/subagent.d.ts +0 -2
  147. package/dist/tui/tool-renderers/subagent.js +0 -114
  148. package/dist/tui/tool-renderers/types.d.ts +0 -36
  149. package/dist/tui/tool-renderers/write-preview.d.ts +0 -12
  150. package/dist/tui/tool-renderers/write-preview.js +0 -30
  151. package/dist/tui/tool-renderers/write.d.ts +0 -6
  152. package/dist/tui/tool-renderers/write.js +0 -88
  153. /package/dist/{tui/tool-renderers → feedback}/types.js +0 -0
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Detect concurrent `bubble serve --feishu` instances for the same App ID.
3
+ *
4
+ * Two processes against the same App ID will fight over the long
5
+ * connection and double-process messages. We record the running PID + appId
6
+ * in `~/.bubble/feishu/processes.json` at startup and remove it at exit.
7
+ *
8
+ * On startup we check the file; any entry whose pid is still alive AND
9
+ * whose appId matches is a conflict.
10
+ */
11
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
12
+ import { getProcessRegistryPath } from "./paths.js";
13
+ export class ProcessRegistry {
14
+ file;
15
+ path;
16
+ constructor() {
17
+ this.path = getProcessRegistryPath();
18
+ this.file = this.read();
19
+ }
20
+ /** Return all entries whose pid is alive AND whose appId matches. */
21
+ findConflicts(appId) {
22
+ return this.file.processes
23
+ .filter((entry) => entry.appId === appId && isPidAlive(entry.pid))
24
+ .map((entry) => ({ entry }));
25
+ }
26
+ /** Kill any conflicting entry's process (SIGTERM). Returns count killed. */
27
+ killConflicts(appId) {
28
+ const conflicts = this.findConflicts(appId);
29
+ let killed = 0;
30
+ for (const c of conflicts) {
31
+ try {
32
+ process.kill(c.entry.pid, "SIGTERM");
33
+ killed++;
34
+ }
35
+ catch {
36
+ // process gone or no permission — fall through to next.
37
+ }
38
+ }
39
+ this.gc();
40
+ return killed;
41
+ }
42
+ /** Remove dead entries from disk. */
43
+ gc() {
44
+ this.file.processes = this.file.processes.filter((e) => isPidAlive(e.pid));
45
+ this.flush();
46
+ }
47
+ register(entry) {
48
+ // Replace any existing entry for this pid.
49
+ this.file.processes = this.file.processes.filter((e) => e.pid !== entry.pid);
50
+ this.file.processes.push(entry);
51
+ this.flush();
52
+ }
53
+ deregister(pid) {
54
+ this.file.processes = this.file.processes.filter((e) => e.pid !== pid);
55
+ this.flush();
56
+ }
57
+ read() {
58
+ if (!existsSync(this.path))
59
+ return { version: 1, processes: [] };
60
+ try {
61
+ const raw = readFileSync(this.path, "utf8");
62
+ const parsed = JSON.parse(raw);
63
+ if (parsed.version !== 1 || !Array.isArray(parsed.processes)) {
64
+ return { version: 1, processes: [] };
65
+ }
66
+ return parsed;
67
+ }
68
+ catch {
69
+ return { version: 1, processes: [] };
70
+ }
71
+ }
72
+ flush() {
73
+ try {
74
+ writeFileSync(this.path, JSON.stringify(this.file, null, 2), { encoding: "utf8", mode: 0o600 });
75
+ }
76
+ catch {
77
+ // Best effort.
78
+ }
79
+ }
80
+ }
81
+ function isPidAlive(pid) {
82
+ try {
83
+ // Signal 0 doesn't deliver but checks existence.
84
+ process.kill(pid, 0);
85
+ return true;
86
+ }
87
+ catch {
88
+ return false;
89
+ }
90
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * In-chat slash-command parser and handlers.
3
+ *
4
+ * Commands run synchronously (no agent involvement) and reply with a plain
5
+ * text or markdown message. Admin commands silently no-op for non-admins
6
+ * (no error reply — that would expose the bot's existence).
7
+ */
8
+ import type { BubbleChannel } from "../channel/channel.js";
9
+ import type { ScopeRegistry } from "../scope/scope-registry.js";
10
+ import type { SessionStore } from "../scope/session-store.js";
11
+ import type { SessionBinder } from "../scope/session-binder.js";
12
+ import type { ActiveRuns } from "../runtime/active-runs.js";
13
+ import type { ScopeConfig, ScopeKey } from "../types.js";
14
+ export interface CommandContext {
15
+ channel: BubbleChannel;
16
+ scopeRegistry: ScopeRegistry;
17
+ sessionStore: SessionStore;
18
+ sessionBinder: SessionBinder;
19
+ activeRuns: ActiveRuns;
20
+ }
21
+ export interface CommandInput {
22
+ chatId: string;
23
+ userId: string;
24
+ scope: ScopeConfig;
25
+ scopeKey: ScopeKey;
26
+ raw: string;
27
+ /** Reply target for ephemeral confirms. */
28
+ replyTo?: string;
29
+ }
30
+ export type CommandHandler = (input: CommandInput, args: string, ctx: CommandContext) => Promise<void>;
31
+ export declare function isSlashCommand(text: string): boolean;
32
+ /**
33
+ * Try to dispatch a slash command. Returns true if a command was matched
34
+ * and handled (regardless of success). Returns false if no command matched
35
+ * — caller should treat the message as a normal agent prompt.
36
+ */
37
+ export declare function dispatchCommand(input: CommandInput, ctx: CommandContext): Promise<boolean>;
38
+ export declare function listCommandNames(): string[];
@@ -0,0 +1,285 @@
1
+ /**
2
+ * In-chat slash-command parser and handlers.
3
+ *
4
+ * Commands run synchronously (no agent involvement) and reply with a plain
5
+ * text or markdown message. Admin commands silently no-op for non-admins
6
+ * (no error reply — that would expose the bot's existence).
7
+ */
8
+ import { resolve as resolvePath, isAbsolute as isAbsolutePath } from "node:path";
9
+ import { existsSync, statSync } from "node:fs";
10
+ import { homedir } from "node:os";
11
+ import { formatPermissionMode, isPermissionModeName } from "../format.js";
12
+ const COMMANDS = [];
13
+ function register(def) {
14
+ COMMANDS.push(def);
15
+ }
16
+ export function isSlashCommand(text) {
17
+ const t = text.trim();
18
+ return t.startsWith("/") && !t.startsWith("//");
19
+ }
20
+ /**
21
+ * Try to dispatch a slash command. Returns true if a command was matched
22
+ * and handled (regardless of success). Returns false if no command matched
23
+ * — caller should treat the message as a normal agent prompt.
24
+ */
25
+ export async function dispatchCommand(input, ctx) {
26
+ const t = input.raw.trim();
27
+ if (!isSlashCommand(t))
28
+ return false;
29
+ const space = t.indexOf(" ");
30
+ const head = (space === -1 ? t : t.slice(0, space)).toLowerCase();
31
+ const args = space === -1 ? "" : t.slice(space + 1).trim();
32
+ const def = COMMANDS.find((c) => `/${c.name}` === head);
33
+ if (!def) {
34
+ await ctx.channel.send(input.chatId, {
35
+ text: `未知命令 ${head}。发 /help 查看可用命令。`,
36
+ }, input.replyTo ? { replyTo: input.replyTo } : undefined);
37
+ return true;
38
+ }
39
+ if (def.adminOnly && !input.scope.admins.includes(input.userId)) {
40
+ // Silent drop for non-admins.
41
+ return true;
42
+ }
43
+ await def.handler(input, args, ctx);
44
+ return true;
45
+ }
46
+ // ---- /help ----
47
+ register({
48
+ name: "help",
49
+ description: "列出可用命令",
50
+ adminOnly: false,
51
+ handler: async (input, _args, ctx) => {
52
+ const isAdmin = input.scope.admins.includes(input.userId);
53
+ const lines = ["**Bubble 飞书命令**"];
54
+ for (const c of COMMANDS) {
55
+ if (c.adminOnly && !isAdmin)
56
+ continue;
57
+ lines.push(`- \`/${c.name}\` — ${c.description}`);
58
+ }
59
+ await ctx.channel.send(input.chatId, { text: lines.join("\n") });
60
+ },
61
+ });
62
+ // ---- /status ----
63
+ register({
64
+ name: "status",
65
+ description: "显示当前 scope / session / mode / 网络状态",
66
+ adminOnly: false,
67
+ handler: async (input, _args, ctx) => {
68
+ const entry = ctx.sessionStore.get(input.scopeKey);
69
+ const wsStatus = ctx.channel.getStatus();
70
+ const active = ctx.activeRuns.isActive(input.scopeKey);
71
+ const lines = [
72
+ `📁 cwd: \`${entry?.cwd ?? input.scope.cwd}\``,
73
+ `🛡 mode: \`${formatPermissionMode(entry?.permissionMode ?? input.scope.defaultPermissionMode)}\``,
74
+ `📄 session: \`${entry?.sessionFile ?? "(not yet started)"}\``,
75
+ `🔌 ws: \`${wsStatus?.state ?? "unknown"}\``,
76
+ `🤖 run: ${active ? "运行中" : "空闲"}`,
77
+ ];
78
+ await ctx.channel.send(input.chatId, { text: lines.join("\n") });
79
+ },
80
+ });
81
+ // ---- /cwd ----
82
+ register({
83
+ name: "cwd",
84
+ description: "显示当前 cwd",
85
+ adminOnly: false,
86
+ handler: async (input, _args, ctx) => {
87
+ const entry = ctx.sessionStore.get(input.scopeKey);
88
+ const cwd = entry?.cwd ?? input.scope.cwd;
89
+ await ctx.channel.send(input.chatId, { text: `📁 \`${cwd}\`` });
90
+ },
91
+ });
92
+ // ---- /cd ----
93
+ register({
94
+ name: "cd",
95
+ description: "切换到新目录并开新 session(绝对路径或 ~/...)",
96
+ adminOnly: false,
97
+ handler: async (input, args, ctx) => {
98
+ if (!args) {
99
+ await ctx.channel.send(input.chatId, { text: "用法:`/cd /absolute/path` 或 `/cd ~/projects/foo`" });
100
+ return;
101
+ }
102
+ const target = expandUser(args);
103
+ if (!isAbsolutePath(target)) {
104
+ await ctx.channel.send(input.chatId, { text: "❌ 路径必须是绝对路径或以 ~ 开头" });
105
+ return;
106
+ }
107
+ const resolved = resolvePath(target);
108
+ if (!existsSync(resolved)) {
109
+ await ctx.channel.send(input.chatId, { text: `❌ 路径不存在:\`${resolved}\`` });
110
+ return;
111
+ }
112
+ let isDir = false;
113
+ try {
114
+ isDir = statSync(resolved).isDirectory();
115
+ }
116
+ catch { /* */ }
117
+ if (!isDir) {
118
+ await ctx.channel.send(input.chatId, { text: `❌ 不是目录:\`${resolved}\`` });
119
+ return;
120
+ }
121
+ // Abort any in-flight run for this scope before swapping cwd.
122
+ ctx.activeRuns.abort(input.scopeKey);
123
+ const next = ctx.sessionBinder.changeCwd(input.scopeKey, resolved);
124
+ await ctx.channel.send(input.chatId, {
125
+ text: `✅ 已切换到 \`${next.cwd}\`,开新 session。`,
126
+ });
127
+ },
128
+ });
129
+ // ---- /mode ----
130
+ register({
131
+ name: "mode",
132
+ description: "切换 permission mode(default / plan / bypassPermissions)",
133
+ adminOnly: false,
134
+ handler: async (input, args, ctx) => {
135
+ if (!args) {
136
+ const entry = ctx.sessionStore.get(input.scopeKey);
137
+ const current = entry?.permissionMode ?? input.scope.defaultPermissionMode;
138
+ await ctx.channel.send(input.chatId, {
139
+ text: `当前 mode: \`${formatPermissionMode(current)}\`\n用法:\`/mode <name>\` (default/plan/bypassPermissions)`,
140
+ });
141
+ return;
142
+ }
143
+ if (!isPermissionModeName(args)) {
144
+ await ctx.channel.send(input.chatId, {
145
+ text: `❌ 无效 mode \`${args}\`。可选:default / plan / bypassPermissions`,
146
+ });
147
+ return;
148
+ }
149
+ const mode = args;
150
+ // Ensure session entry exists so we have something to update.
151
+ const entry = ctx.sessionStore.get(input.scopeKey);
152
+ if (!entry) {
153
+ // Bootstrap session at fallback cwd, then set mode.
154
+ ctx.sessionBinder.openOrBootstrap(input.scopeKey, input.scope.cwd, mode);
155
+ }
156
+ else {
157
+ ctx.sessionStore.setPermissionMode(input.scopeKey, mode);
158
+ }
159
+ await ctx.channel.send(input.chatId, {
160
+ text: `🛡 mode 已切换为 \`${formatPermissionMode(mode)}\``,
161
+ });
162
+ },
163
+ });
164
+ // ---- /new ----
165
+ register({
166
+ name: "new",
167
+ description: "归档当前 session,开始新对话",
168
+ adminOnly: false,
169
+ handler: async (input, _args, ctx) => {
170
+ ctx.activeRuns.abort(input.scopeKey);
171
+ const entry = ctx.sessionStore.get(input.scopeKey);
172
+ const cwd = entry?.cwd ?? input.scope.cwd;
173
+ const mode = entry?.permissionMode ?? input.scope.defaultPermissionMode;
174
+ ctx.sessionBinder.createFresh(input.scopeKey, cwd, mode);
175
+ await ctx.channel.send(input.chatId, { text: "✨ 已开新 session。" });
176
+ },
177
+ });
178
+ // ---- /resume ----
179
+ register({
180
+ name: "resume",
181
+ description: "列出最近 session 让你选(不带参数)或恢复指定 session 名",
182
+ adminOnly: false,
183
+ handler: async (input, args, ctx) => {
184
+ const entry = ctx.sessionStore.get(input.scopeKey);
185
+ const cwd = entry?.cwd ?? input.scope.cwd;
186
+ if (!args) {
187
+ const recent = ctx.sessionBinder.listResumable(cwd, 10);
188
+ if (recent.length === 0) {
189
+ await ctx.channel.send(input.chatId, { text: "(这个目录下还没有 session 可恢复)" });
190
+ return;
191
+ }
192
+ const lines = ["最近 session:", ""];
193
+ for (const s of recent) {
194
+ const stamp = new Date(s.mtime).toISOString().slice(0, 19).replace("T", " ");
195
+ lines.push(`- \`${s.name}\` · ${stamp} · ${s.messageCount} msgs · ${s.firstUserMessage.slice(0, 50)}`);
196
+ }
197
+ lines.push("");
198
+ lines.push("用 `/resume <name>` 恢复。");
199
+ await ctx.channel.send(input.chatId, { text: lines.join("\n") });
200
+ return;
201
+ }
202
+ const recent = ctx.sessionBinder.listResumable(cwd, 50);
203
+ const match = recent.find((s) => s.name === args || s.file.endsWith(args));
204
+ if (!match) {
205
+ await ctx.channel.send(input.chatId, { text: `❌ 没找到 session \`${args}\`` });
206
+ return;
207
+ }
208
+ ctx.activeRuns.abort(input.scopeKey);
209
+ const opened = ctx.sessionBinder.resumeNamed(input.scopeKey, match.file);
210
+ if (!opened) {
211
+ await ctx.channel.send(input.chatId, { text: `❌ 恢复失败:\`${args}\`` });
212
+ return;
213
+ }
214
+ await ctx.channel.send(input.chatId, { text: `✅ 已恢复 \`${match.name}\` (cwd: ${opened.cwd})` });
215
+ },
216
+ });
217
+ // ---- /stop ----
218
+ register({
219
+ name: "stop",
220
+ description: "中断当前运行",
221
+ adminOnly: false,
222
+ handler: async (input, _args, ctx) => {
223
+ const aborted = ctx.activeRuns.abort(input.scopeKey);
224
+ await ctx.channel.send(input.chatId, {
225
+ text: aborted ? "⏹ 已请求中断。" : "(当前没有正在运行的任务)",
226
+ });
227
+ },
228
+ });
229
+ // ---- /clear ----
230
+ register({
231
+ name: "clear",
232
+ description: "在当前 session 里插入清除标记(保留历史文件,但后续对话不再带入旧上下文)",
233
+ adminOnly: false,
234
+ handler: async (input, _args, ctx) => {
235
+ const entry = ctx.sessionStore.get(input.scopeKey);
236
+ if (!entry) {
237
+ await ctx.channel.send(input.chatId, { text: "(还没有 session 可清除)" });
238
+ return;
239
+ }
240
+ const opened = ctx.sessionBinder.openOrBootstrap(input.scopeKey, entry.cwd, entry.permissionMode);
241
+ opened.manager.appendMarker("conversation_clear", String(Date.now()));
242
+ await ctx.channel.send(input.chatId, { text: "🧹 已插入清除标记。下次发消息从空上下文开始。" });
243
+ },
244
+ });
245
+ // ---- /whoami ----
246
+ register({
247
+ name: "whoami",
248
+ description: "显示你的 open_id",
249
+ adminOnly: false,
250
+ handler: async (input, _args, ctx) => {
251
+ const isAdmin = input.scope.admins.includes(input.userId) ? "(admin)" : "";
252
+ await ctx.channel.send(input.chatId, {
253
+ text: `👤 \`${input.userId}\` ${isAdmin}`,
254
+ });
255
+ },
256
+ });
257
+ // ---- /config ----
258
+ register({
259
+ name: "config",
260
+ description: "[admin] 显示当前 scope 配置(只读)",
261
+ adminOnly: true,
262
+ handler: async (input, _args, ctx) => {
263
+ const lines = [
264
+ "**scope 配置:**",
265
+ `- chatId: \`${input.chatId}\``,
266
+ `- displayName: \`${input.scope.displayName}\``,
267
+ `- initial cwd: \`${input.scope.cwd}\``,
268
+ `- defaultPermissionMode: \`${formatPermissionMode(input.scope.defaultPermissionMode)}\``,
269
+ `- allowedUsers (${input.scope.allowedUsers.length}):`,
270
+ ...input.scope.allowedUsers.map((u) => ` - \`${u}\``),
271
+ `- admins (${input.scope.admins.length}):`,
272
+ ...input.scope.admins.map((u) => ` - \`${u}\``),
273
+ ];
274
+ await ctx.channel.send(input.chatId, { text: lines.join("\n") });
275
+ },
276
+ });
277
+ function expandUser(p) {
278
+ if (p === "~" || p.startsWith("~/")) {
279
+ return homedir() + p.slice(1);
280
+ }
281
+ return p;
282
+ }
283
+ export function listCommandNames() {
284
+ return COMMANDS.map((c) => c.name);
285
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Top-level inbound dispatcher.
3
+ *
4
+ * Wires `channel.onMessage` and `channel.onCardAction` to:
5
+ * - run the three whitelist gates
6
+ * - dispatch slash commands (sync, no agent)
7
+ * - hand normal text to the PendingQueue (which eventually fires the
8
+ * RunDriver via the flush callback)
9
+ * - route cardAction events to the approval UI or stop-button handler
10
+ */
11
+ import type { BubbleChannel } from "../channel/channel.js";
12
+ import { type CommandContext } from "./commands.js";
13
+ import type { ScopeRegistry } from "../scope/scope-registry.js";
14
+ import type { SessionStore } from "../scope/session-store.js";
15
+ import type { ActiveRuns } from "../runtime/active-runs.js";
16
+ import type { PendingQueue } from "../runtime/pending-queue.js";
17
+ import type { FeishuApprovalUI } from "../agent-host/approval-ui.js";
18
+ import type { FeishuLogger } from "../logger.js";
19
+ export interface EventRouterOptions {
20
+ channel: BubbleChannel;
21
+ scopeRegistry: ScopeRegistry;
22
+ sessionStore: SessionStore;
23
+ activeRuns: ActiveRuns;
24
+ pendingQueue: PendingQueue;
25
+ approvalUI: FeishuApprovalUI;
26
+ logger: FeishuLogger;
27
+ commandContext: CommandContext;
28
+ requireMentionInGroup: boolean;
29
+ }
30
+ export declare class EventRouter {
31
+ private readonly opts;
32
+ private unsubMessage?;
33
+ private unsubCardAction?;
34
+ private unsubReject?;
35
+ constructor(opts: EventRouterOptions);
36
+ start(): void;
37
+ stop(): void;
38
+ private handleMessage;
39
+ private handleCardAction;
40
+ }
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Top-level inbound dispatcher.
3
+ *
4
+ * Wires `channel.onMessage` and `channel.onCardAction` to:
5
+ * - run the three whitelist gates
6
+ * - dispatch slash commands (sync, no agent)
7
+ * - hand normal text to the PendingQueue (which eventually fires the
8
+ * RunDriver via the flush callback)
9
+ * - route cardAction events to the approval UI or stop-button handler
10
+ */
11
+ import { dispatchCommand, isSlashCommand } from "./commands.js";
12
+ import { checkWhitelist } from "./whitelist.js";
13
+ import { makeScopeKey } from "../types.js";
14
+ export class EventRouter {
15
+ opts;
16
+ unsubMessage;
17
+ unsubCardAction;
18
+ unsubReject;
19
+ constructor(opts) {
20
+ this.opts = opts;
21
+ }
22
+ start() {
23
+ this.unsubMessage = this.opts.channel.onMessage(async (msg) => {
24
+ this.opts.logger.info("message_received", {
25
+ phase: "router",
26
+ chatId: msg.chatId,
27
+ userId: msg.senderId,
28
+ messageId: msg.messageId,
29
+ chatType: msg.chatType,
30
+ contentType: msg.rawContentType,
31
+ mentionedBot: msg.mentionedBot,
32
+ });
33
+ try {
34
+ await this.handleMessage(msg);
35
+ }
36
+ catch (err) {
37
+ this.opts.logger.error("router_message_error", {
38
+ phase: "router",
39
+ chatId: msg.chatId,
40
+ userId: msg.senderId,
41
+ error: serializeError(err),
42
+ });
43
+ }
44
+ });
45
+ this.unsubCardAction = this.opts.channel.onCardAction(async (evt) => {
46
+ try {
47
+ await this.handleCardAction(evt);
48
+ }
49
+ catch (err) {
50
+ this.opts.logger.error("router_card_action_error", {
51
+ phase: "router",
52
+ chatId: evt.chatId,
53
+ error: serializeError(err),
54
+ });
55
+ }
56
+ });
57
+ // Subscribe to SDK-level policy rejects so we can see if messages are
58
+ // being dropped before reaching our handler.
59
+ this.unsubReject = this.opts.channel.onReject((evt) => {
60
+ this.opts.logger.warn("sdk_reject", {
61
+ phase: "channel",
62
+ chatId: evt.chatId,
63
+ userId: evt.senderId,
64
+ messageId: evt.messageId,
65
+ reason: evt.reason,
66
+ });
67
+ });
68
+ }
69
+ stop() {
70
+ this.unsubMessage?.();
71
+ this.unsubCardAction?.();
72
+ this.unsubReject?.();
73
+ }
74
+ async handleMessage(msg) {
75
+ // Only text messages are processed in v1.
76
+ if (msg.rawContentType !== "text") {
77
+ this.opts.logger.debug("skip_non_text", {
78
+ phase: "router",
79
+ chatId: msg.chatId,
80
+ userId: msg.senderId,
81
+ kind: msg.rawContentType,
82
+ });
83
+ return;
84
+ }
85
+ // LarkChannel already normalizes text out of the raw `{"text":"..."}`
86
+ // JSON wrapper, so msg.content is the plain string we want.
87
+ const text = msg.content ?? "";
88
+ if (!text.trim()) {
89
+ this.opts.logger.debug("skip_empty_text", {
90
+ phase: "router",
91
+ chatId: msg.chatId,
92
+ userId: msg.senderId,
93
+ });
94
+ return;
95
+ }
96
+ const scope = this.opts.scopeRegistry.get(msg.chatId);
97
+ // Get chat mode from event (normalized to p2p/group; topic must be detected via getChatMode)
98
+ let chatMode = msg.chatType;
99
+ if (msg.chatType === "group") {
100
+ // Cheap check: if event already says group, fetch chat mode to detect topic.
101
+ try {
102
+ chatMode = await this.opts.channel.getChatMode(msg.chatId);
103
+ }
104
+ catch {
105
+ // Default to group if API call fails.
106
+ chatMode = "group";
107
+ }
108
+ }
109
+ const gate = checkWhitelist({
110
+ chatId: msg.chatId,
111
+ userId: msg.senderId,
112
+ chatType: chatMode,
113
+ mentionedBot: msg.mentionedBot,
114
+ scope,
115
+ requireMentionInGroup: this.opts.requireMentionInGroup,
116
+ });
117
+ if (!gate.ok) {
118
+ this.opts.logger.info("gate_rejected", {
119
+ phase: "router",
120
+ chatId: msg.chatId,
121
+ userId: msg.senderId,
122
+ reason: gate.reason,
123
+ });
124
+ // topic_chat is the one case where we DO reply, to help the user — they
125
+ // configured the chat but can't use it.
126
+ if (gate.reason === "topic_chat_unsupported") {
127
+ await this.opts.channel.send(msg.chatId, {
128
+ text: "⚠️ 暂不支持话题群,请用普通群或私聊。",
129
+ });
130
+ }
131
+ return;
132
+ }
133
+ if (!scope)
134
+ return; // unreachable (gate.ok implies scope exists)
135
+ const scopeKey = makeScopeKey(msg.chatId, msg.senderId);
136
+ const cleanText = stripBotMention(text);
137
+ // Slash command?
138
+ if (isSlashCommand(cleanText)) {
139
+ await dispatchCommand({
140
+ chatId: msg.chatId,
141
+ userId: msg.senderId,
142
+ scope,
143
+ scopeKey,
144
+ raw: cleanText,
145
+ replyTo: msg.messageId,
146
+ }, this.opts.commandContext);
147
+ this.opts.scopeRegistry.touch(msg.chatId);
148
+ return;
149
+ }
150
+ // Normal agent prompt — push to queue.
151
+ this.opts.pendingQueue.push(scopeKey, {
152
+ text: cleanText,
153
+ messageId: msg.messageId,
154
+ receivedAt: Date.now(),
155
+ });
156
+ this.opts.scopeRegistry.touch(msg.chatId);
157
+ }
158
+ async handleCardAction(evt) {
159
+ const value = evt.action.value;
160
+ if (!value || typeof value !== "object")
161
+ return;
162
+ // Approval card button.
163
+ if (value.__bubble === "approval") {
164
+ await this.opts.approvalUI.dispatch({
165
+ cardMessageId: evt.messageId,
166
+ clickerOpenId: evt.operator.openId,
167
+ value,
168
+ });
169
+ return;
170
+ }
171
+ // Stop button on the run-state card.
172
+ if (value.__bubble === "stop_run") {
173
+ const scope = this.opts.scopeRegistry.get(evt.chatId);
174
+ if (!scope)
175
+ return;
176
+ if (!scope.allowedUsers.includes(evt.operator.openId))
177
+ return;
178
+ const scopeKey = makeScopeKey(evt.chatId, evt.operator.openId);
179
+ this.opts.activeRuns.abort(scopeKey);
180
+ this.opts.logger.info("stop_button_clicked", {
181
+ phase: "router",
182
+ chatId: evt.chatId,
183
+ userId: evt.operator.openId,
184
+ });
185
+ return;
186
+ }
187
+ // Unknown button — log and ignore.
188
+ this.opts.logger.debug("unknown_card_action", {
189
+ phase: "router",
190
+ chatId: evt.chatId,
191
+ value: JSON.stringify(value),
192
+ });
193
+ }
194
+ }
195
+ /**
196
+ * Strip leading `@_user_X` or `@bot` tokens that Feishu injects when the
197
+ * user @-mentions the bot. Specific tokens vary by tenant; a generic
198
+ * pattern handles the common cases.
199
+ */
200
+ function stripBotMention(text) {
201
+ return text.replace(/^@[\w_]+\s+/g, "").trim();
202
+ }
203
+ function serializeError(err) {
204
+ if (err instanceof Error) {
205
+ return { message: err.message, name: err.name, stack: err.stack };
206
+ }
207
+ return { message: String(err) };
208
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Three gates: scope (chat known?), user (in allowedUsers?), mention (group + requireMention?).
3
+ *
4
+ * Failed gates return reasons that the caller may log but should NOT reflect
5
+ * back to the user (silent drop) — surfacing the bot's existence is its own
6
+ * security exposure.
7
+ */
8
+ import type { ScopeConfig } from "../types.js";
9
+ export type WhitelistResult = {
10
+ ok: true;
11
+ } | {
12
+ ok: false;
13
+ reason: "scope_not_found" | "user_not_allowed" | "no_mention_in_group" | "topic_chat_unsupported";
14
+ };
15
+ export interface WhitelistCheckInput {
16
+ chatId: string;
17
+ userId: string;
18
+ chatType: "p2p" | "group" | "topic";
19
+ mentionedBot: boolean;
20
+ scope: ScopeConfig | undefined;
21
+ requireMentionInGroup: boolean;
22
+ }
23
+ export declare function checkWhitelist(input: WhitelistCheckInput): WhitelistResult;