@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,288 @@
1
+ /**
2
+ * `bubble serve --feishu` entry. Wires every layer of the host together.
3
+ */
4
+ import chalk from "chalk";
5
+ import { homedir } from "node:os";
6
+ import { configExists, loadConfig, resolveAppSecret } from "./config.js";
7
+ import { runWizard } from "./wizard.js";
8
+ import { ScopeRegistry } from "./scope/scope-registry.js";
9
+ import { SessionStore } from "./scope/session-store.js";
10
+ import { SessionBinder } from "./scope/session-binder.js";
11
+ import { PendingQueue, combineQueuedMessages } from "./runtime/pending-queue.js";
12
+ import { ActiveRuns } from "./runtime/active-runs.js";
13
+ import { ProcessPool } from "./runtime/process-pool.js";
14
+ import { createBubbleChannel } from "./channel/channel.js";
15
+ import { FeishuApprovalUI } from "./agent-host/approval-ui.js";
16
+ import { RunDriver } from "./agent-host/run-driver.js";
17
+ import { EventRouter } from "./router/event-router.js";
18
+ import { FeishuLogger } from "./logger.js";
19
+ import { ProcessRegistry } from "./process-registry.js";
20
+ import { parseScopeKey } from "./types.js";
21
+ // Re-use existing process-level building blocks.
22
+ import { UserConfig } from "../config.js";
23
+ import { ProviderRegistry } from "../provider-registry.js";
24
+ import { createProviderInstance } from "../provider.js";
25
+ import { SettingsManager } from "../permissions/settings.js";
26
+ import { SkillRegistry } from "../skills/registry.js";
27
+ import { loadMcpConfig } from "../mcp/config.js";
28
+ import { McpManager } from "../mcp/manager.js";
29
+ import { BashAllowlist } from "../approval/session-cache.js";
30
+ export async function serveFeishu(opts = {}) {
31
+ // 1. Setup or load config
32
+ if (opts.setup || !configExists()) {
33
+ await runWizard();
34
+ if (opts.setup) {
35
+ console.log(chalk.dim("Re-run `bubble serve --feishu` (without --setup) to start serving."));
36
+ return;
37
+ }
38
+ }
39
+ const config = loadConfig();
40
+ const appSecret = resolveAppSecret(config);
41
+ // 2. Process registry — detect duplicates
42
+ const procRegistry = new ProcessRegistry();
43
+ procRegistry.gc();
44
+ const conflicts = procRegistry.findConflicts(config.app.appId);
45
+ if (conflicts.length > 0) {
46
+ if (opts.killOld || process.env.BUBBLE_KILL_OLD === "1") {
47
+ const killed = procRegistry.killConflicts(config.app.appId);
48
+ console.log(chalk.dim(`Killed ${killed} stale instance(s) for appId ${config.app.appId}.`));
49
+ }
50
+ else if (process.stdin.isTTY) {
51
+ console.log(chalk.yellow(`\n⚠ Another bubble serve --feishu is running for app ${config.app.appId}:`));
52
+ for (const c of conflicts) {
53
+ console.log(` pid ${c.entry.pid} (started ${new Date(c.entry.startedAt).toLocaleString()})`);
54
+ }
55
+ console.log("\n c) Continue anyway (both will fight for events — not recommended)");
56
+ console.log(" k) Kill the old one and continue");
57
+ console.log(" a) Abort\n");
58
+ const choice = await prompt("Choice [c/k/a]: ");
59
+ if (/^k/i.test(choice)) {
60
+ procRegistry.killConflicts(config.app.appId);
61
+ }
62
+ else if (!/^c/i.test(choice)) {
63
+ console.log("Aborted.");
64
+ process.exit(2);
65
+ }
66
+ }
67
+ else {
68
+ console.error(chalk.red(`Another instance is running for appId ${config.app.appId}. Set BUBBLE_KILL_OLD=1 to kill it, or run without -y to interactively resolve.`));
69
+ process.exit(2);
70
+ }
71
+ }
72
+ procRegistry.register({
73
+ pid: process.pid,
74
+ appId: config.app.appId,
75
+ startedAt: Date.now(),
76
+ cwd: process.cwd(),
77
+ });
78
+ // 3. Process-level dependencies (shared across scopes)
79
+ const userConfig = new UserConfig();
80
+ const providerRegistry = new ProviderRegistry(userConfig);
81
+ // Use the user's home as the "root" for settings/skills/MCP discovery.
82
+ // Per-scope cwd overrides happen at the run-driver level (future work).
83
+ const rootCwd = homedir();
84
+ const settingsManager = new SettingsManager(rootCwd);
85
+ const skillRegistry = new SkillRegistry({
86
+ cwd: rootCwd,
87
+ skillPaths: userConfig.getSkillPaths(),
88
+ });
89
+ const mcpLoaded = loadMcpConfig({ cwd: rootCwd });
90
+ const mcpManager = new McpManager({ servers: mcpLoaded.servers });
91
+ if (mcpLoaded.servers.length > 0) {
92
+ await mcpManager.start();
93
+ }
94
+ const createProvider = (providerId, apiKey, baseURL) => createProviderInstance({ providerId, apiKey, baseURL });
95
+ const createProviderForRoute = async (route) => {
96
+ const target = providerRegistry.getConfigured().find((p) => p.id === route.providerId);
97
+ if (!target?.apiKey) {
98
+ throw new Error(`Subagent route requires provider "${route.providerId}", not configured.`);
99
+ }
100
+ return createProvider(route.providerId, target.apiKey, target.baseURL);
101
+ };
102
+ const deps = {
103
+ settingsManager,
104
+ providerRegistry,
105
+ userConfig,
106
+ skillRegistry,
107
+ mcpManager,
108
+ createProvider,
109
+ createProviderForRoute,
110
+ ownerOpenId: config.app.ownerOpenId,
111
+ };
112
+ // 4. Persistence stores
113
+ const scopeRegistry = ScopeRegistry.load();
114
+ const sessionStore = SessionStore.load();
115
+ const sessionBinder = new SessionBinder(sessionStore);
116
+ // 5. Channel
117
+ const channel = createBubbleChannel({
118
+ appId: config.app.appId,
119
+ appSecret,
120
+ outputThrottleMs: config.preferences.outputThrottleMs,
121
+ requireMentionInGroup: config.preferences.requireMentionInGroup,
122
+ });
123
+ // 6. Logger
124
+ const logger = new FeishuLogger();
125
+ logger.pruneOldLogs(7);
126
+ // 7. Approval UI (shared across all scopes; clicker-restricted enforces per-scope safety)
127
+ const approvalUI = new FeishuApprovalUI({
128
+ sendCard: async (chatId, card) => {
129
+ const res = await channel.send(chatId, { card }, undefined);
130
+ return { messageId: res.messageId };
131
+ },
132
+ updateCard: (messageId, card) => channel.updateCard(messageId, card),
133
+ bashAllowlist: new BashAllowlist(),
134
+ timeoutMs: 60_000,
135
+ });
136
+ // 8. Runtime control
137
+ const activeRuns = new ActiveRuns();
138
+ const processPool = new ProcessPool({ concurrency: config.globalLimits.maxConcurrentRuns });
139
+ // 9. Run driver
140
+ const driver = new RunDriver({
141
+ channel,
142
+ deps,
143
+ binder: sessionBinder,
144
+ approvalUI,
145
+ outputThrottleMs: config.preferences.outputThrottleMs,
146
+ idleTimeoutMinutes: config.preferences.idleTimeoutMinutes,
147
+ maxBytesPerElement: config.preferences.maxBytesPerElement,
148
+ maxBytesPerCard: config.preferences.maxBytesPerCard,
149
+ });
150
+ // 10. PendingQueue with flush → run-driver
151
+ const pendingQueue = new PendingQueue({
152
+ debounceMs: 600,
153
+ onFlush: async (scopeKey, batch) => {
154
+ const parsed = parseScopeKey(scopeKey);
155
+ if (!parsed)
156
+ return;
157
+ const scope = scopeRegistry.get(parsed.chatId);
158
+ if (!scope)
159
+ return;
160
+ // Block further flushes for this scope while the run is in flight.
161
+ pendingQueue.block(scopeKey);
162
+ const { signal, complete } = await activeRuns.startOrReplace(scopeKey);
163
+ try {
164
+ await processPool.run(async () => driver.runOnce({
165
+ scopeKey,
166
+ scope,
167
+ chatId: parsed.chatId,
168
+ userId: parsed.userId,
169
+ prompt: combineQueuedMessages(batch),
170
+ replyToMessageId: batch[0]?.messageId,
171
+ abortSignal: signal,
172
+ }));
173
+ }
174
+ catch (err) {
175
+ logger.error("run_driver_error", {
176
+ phase: "runtime",
177
+ scope: scopeKey,
178
+ error: serializeError(err),
179
+ });
180
+ }
181
+ finally {
182
+ complete();
183
+ pendingQueue.unblock(scopeKey);
184
+ }
185
+ },
186
+ });
187
+ // 11. Event router
188
+ const router = new EventRouter({
189
+ channel,
190
+ scopeRegistry,
191
+ sessionStore,
192
+ activeRuns,
193
+ pendingQueue,
194
+ approvalUI,
195
+ logger,
196
+ requireMentionInGroup: config.preferences.requireMentionInGroup,
197
+ commandContext: {
198
+ channel,
199
+ scopeRegistry,
200
+ sessionStore,
201
+ sessionBinder,
202
+ activeRuns,
203
+ },
204
+ });
205
+ // 12. Channel events for status/log
206
+ channel.onError((err) => {
207
+ logger.error("channel_error", { phase: "channel", error: serializeError(err) });
208
+ console.error(chalk.red(`[channel] ${err.message}`));
209
+ });
210
+ channel.onReconnecting(() => {
211
+ logger.warn("channel_reconnecting", { phase: "channel" });
212
+ console.log(chalk.yellow("[channel] reconnecting…"));
213
+ });
214
+ channel.onReconnected(() => {
215
+ logger.info("channel_reconnected", { phase: "channel" });
216
+ console.log(chalk.green("[channel] reconnected"));
217
+ });
218
+ router.start();
219
+ // 13. Shutdown
220
+ let shuttingDown = false;
221
+ let resolveServeDone;
222
+ const serveDone = new Promise((resolve) => {
223
+ resolveServeDone = resolve;
224
+ });
225
+ const shutdown = async (signal) => {
226
+ if (shuttingDown)
227
+ return;
228
+ shuttingDown = true;
229
+ console.log(chalk.dim(`\nGot ${signal}, shutting down…`));
230
+ router.stop();
231
+ pendingQueue.shutdown();
232
+ const aborted = activeRuns.abortAll();
233
+ if (aborted > 0)
234
+ console.log(chalk.dim(`Aborted ${aborted} active run(s).`));
235
+ approvalUI.cancelAll("Shutdown");
236
+ await activeRuns.waitAll(8_000);
237
+ try {
238
+ await channel.disconnect();
239
+ }
240
+ catch { /* */ }
241
+ try {
242
+ await mcpManager.shutdown();
243
+ }
244
+ catch { /* */ }
245
+ procRegistry.deregister(process.pid);
246
+ resolveServeDone?.();
247
+ };
248
+ process.once("SIGINT", () => void shutdown("SIGINT"));
249
+ process.once("SIGTERM", () => void shutdown("SIGTERM"));
250
+ // 14. Connect
251
+ console.log(chalk.dim(`\nConnecting to Feishu (app ${config.app.appId})…`));
252
+ try {
253
+ await channel.connect();
254
+ }
255
+ catch (err) {
256
+ console.error(chalk.red(`Failed to connect: ${err.message}`));
257
+ procRegistry.deregister(process.pid);
258
+ process.exit(1);
259
+ }
260
+ const botId = channel.botOpenId();
261
+ const scopesCount = scopeRegistry.list().length;
262
+ console.log(chalk.green(`✅ Listening on Feishu.`));
263
+ console.log(chalk.dim(` bot open_id: ${botId ?? "(unknown)"}`));
264
+ console.log(chalk.dim(` ${scopesCount} scope${scopesCount === 1 ? "" : "s"} configured`));
265
+ console.log(chalk.dim("\nSend a message to your bot to start. /help in chat for commands."));
266
+ if (opts.dryRun) {
267
+ console.log(chalk.dim("\n--dry-run set; exiting after successful connect."));
268
+ await shutdown("dry-run");
269
+ return;
270
+ }
271
+ // Block here until SIGINT/SIGTERM triggers shutdown. Without this await
272
+ // the function would return, main() would resolve, and exitAfterFlush()
273
+ // in main.ts would call process.exit(0) — killing the freshly-connected
274
+ // service. The LarkChannel WebSocket alone doesn't always keep Node's
275
+ // event loop alive (e.g., under detached spawn where stdin is /dev/null).
276
+ await serveDone;
277
+ }
278
+ function prompt(question) {
279
+ return new Promise((resolve) => {
280
+ process.stdout.write(question);
281
+ process.stdin.once("data", (data) => resolve(String(data).trim()));
282
+ });
283
+ }
284
+ function serializeError(err) {
285
+ if (err instanceof Error)
286
+ return { message: err.message, name: err.name, stack: err.stack };
287
+ return { message: String(err) };
288
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Public types for the Feishu host.
3
+ */
4
+ import type { PermissionMode } from "../types.js";
5
+ export interface FeishuConfig {
6
+ version: 1;
7
+ app: {
8
+ appId: string;
9
+ secretRef: SecretRef;
10
+ ownerOpenId: string;
11
+ encryptCheck: string;
12
+ };
13
+ preferences: {
14
+ outputThrottleMs: number;
15
+ idleTimeoutMinutes: number;
16
+ renderMode: "card" | "markdown" | "text";
17
+ requireMentionInGroup: boolean;
18
+ maxBytesPerElement: number;
19
+ maxBytesPerCard: number;
20
+ };
21
+ globalLimits: {
22
+ maxConcurrentRuns: number;
23
+ };
24
+ }
25
+ export type SecretRef = {
26
+ source: "keystore";
27
+ name: string;
28
+ } | {
29
+ source: "env";
30
+ varName: string;
31
+ };
32
+ export interface ScopesFile {
33
+ version: 1;
34
+ scopes: Record<string, ScopeConfig>;
35
+ }
36
+ export interface ScopeConfig {
37
+ /** Initial cwd used the first time this scope sees traffic. */
38
+ cwd: string;
39
+ displayName: string;
40
+ allowedUsers: string[];
41
+ admins: string[];
42
+ defaultPermissionMode: PermissionMode;
43
+ model: string | null;
44
+ createdAt: number;
45
+ lastActiveAt: number;
46
+ }
47
+ export interface SessionsFile {
48
+ version: 1;
49
+ sessions: Record<string, SessionEntry>;
50
+ }
51
+ /** Keyed by `<chatId>:<userId>` in sessions.json. */
52
+ export interface SessionEntry {
53
+ sessionFile: string;
54
+ cwd: string;
55
+ permissionMode: PermissionMode;
56
+ lastActiveAt: number;
57
+ }
58
+ export interface ProcessRegistryFile {
59
+ version: 1;
60
+ processes: ProcessRegistryEntry[];
61
+ }
62
+ export interface ProcessRegistryEntry {
63
+ pid: number;
64
+ appId: string;
65
+ startedAt: number;
66
+ cwd: string;
67
+ }
68
+ export declare const DEFAULT_PREFERENCES: FeishuConfig["preferences"];
69
+ export declare const DEFAULT_GLOBAL_LIMITS: FeishuConfig["globalLimits"];
70
+ export type ScopeKey = string;
71
+ export declare function makeScopeKey(chatId: string, userId: string): ScopeKey;
72
+ export declare function parseScopeKey(key: ScopeKey): {
73
+ chatId: string;
74
+ userId: string;
75
+ } | undefined;
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Public types for the Feishu host.
3
+ */
4
+ export const DEFAULT_PREFERENCES = {
5
+ outputThrottleMs: 400,
6
+ idleTimeoutMinutes: 15,
7
+ renderMode: "card",
8
+ requireMentionInGroup: true,
9
+ maxBytesPerElement: 28000,
10
+ maxBytesPerCard: 140000,
11
+ };
12
+ export const DEFAULT_GLOBAL_LIMITS = {
13
+ maxConcurrentRuns: 5,
14
+ };
15
+ export function makeScopeKey(chatId, userId) {
16
+ return `${chatId}:${userId}`;
17
+ }
18
+ export function parseScopeKey(key) {
19
+ const idx = key.indexOf(":");
20
+ if (idx <= 0)
21
+ return undefined;
22
+ return { chatId: key.slice(0, idx), userId: key.slice(idx + 1) };
23
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * First-time setup wizard.
3
+ *
4
+ * Flow:
5
+ * 1. Call `registerApp` from the SDK — it returns a QR code URL that
6
+ * points at Feishu's app-registration page; user scans on phone to
7
+ * create an app and authorize us.
8
+ * 2. We receive `{ client_id, client_secret, user_info.open_id }`.
9
+ * 3. Encrypt the secret to `secrets.enc`, write `config.json`, prompt
10
+ * the user (in terminal) for the first scope (chat_id + cwd).
11
+ * 4. Persist the scope to `scopes.json`.
12
+ *
13
+ * The terminal prompts use a tiny line-mode reader (readline). We don't
14
+ * pull in @clack to keep dependencies flat.
15
+ */
16
+ import type { FeishuConfig, ScopeConfig } from "./types.js";
17
+ export interface WizardResult {
18
+ config: FeishuConfig;
19
+ firstScope?: {
20
+ chatId: string;
21
+ scope: ScopeConfig;
22
+ };
23
+ }
24
+ export declare function runWizard(): Promise<WizardResult>;
@@ -0,0 +1,121 @@
1
+ /**
2
+ * First-time setup wizard.
3
+ *
4
+ * Flow:
5
+ * 1. Call `registerApp` from the SDK — it returns a QR code URL that
6
+ * points at Feishu's app-registration page; user scans on phone to
7
+ * create an app and authorize us.
8
+ * 2. We receive `{ client_id, client_secret, user_info.open_id }`.
9
+ * 3. Encrypt the secret to `secrets.enc`, write `config.json`, prompt
10
+ * the user (in terminal) for the first scope (chat_id + cwd).
11
+ * 4. Persist the scope to `scopes.json`.
12
+ *
13
+ * The terminal prompts use a tiny line-mode reader (readline). We don't
14
+ * pull in @clack to keep dependencies flat.
15
+ */
16
+ import { registerApp } from "@larksuiteoapi/node-sdk";
17
+ import qrTerminal from "qrcode-terminal";
18
+ import chalk from "chalk";
19
+ import { createInterface } from "node:readline";
20
+ import { existsSync, statSync } from "node:fs";
21
+ import { resolve as resolvePath, isAbsolute } from "node:path";
22
+ import { homedir } from "node:os";
23
+ import { bootstrapConfig } from "./config.js";
24
+ import { ScopeRegistry } from "./scope/scope-registry.js";
25
+ export async function runWizard() {
26
+ console.log(chalk.bold("\n🫧 Bubble Feishu setup\n"));
27
+ console.log("This will register a Feishu personal-agent app, encrypt the secret to your");
28
+ console.log("keystore, and let you bind your first chat to a local directory.\n");
29
+ const registered = await runQrFlow();
30
+ console.log(chalk.green(`✅ Registered. owner open_id: ${registered.userInfo?.open_id ?? "(unknown)"}\n`));
31
+ const ownerOpenId = registered.userInfo?.open_id ?? "";
32
+ if (!ownerOpenId) {
33
+ throw new Error("registerApp did not return user_info.open_id — cannot continue.");
34
+ }
35
+ const config = bootstrapConfig({
36
+ appId: registered.clientId,
37
+ appSecret: registered.clientSecret,
38
+ ownerOpenId,
39
+ });
40
+ console.log(chalk.dim(`Wrote config + encrypted secret to ~/.bubble/feishu/\n`));
41
+ // Optional first-scope binding.
42
+ console.log("Want to bind a chat to a local directory now? You can also do this later by");
43
+ console.log("editing ~/.bubble/feishu/scopes.json directly.\n");
44
+ const wantBind = await ask("Bind a chat now? [y/N]: ");
45
+ if (!/^y/i.test(wantBind.trim())) {
46
+ return { config };
47
+ }
48
+ const chatId = (await ask("Chat ID (oc_...): ")).trim();
49
+ if (!chatId.startsWith("oc_")) {
50
+ console.log(chalk.yellow("Chat IDs typically start with `oc_`. Continuing anyway."));
51
+ }
52
+ const cwdInput = (await ask(`Local cwd to bind (e.g. ${homedir()}/projects/my-app): `)).trim();
53
+ const expandedCwd = expandUser(cwdInput);
54
+ if (!isAbsolute(expandedCwd) || !existsSync(expandedCwd) || !statSync(expandedCwd).isDirectory()) {
55
+ throw new Error(`Invalid cwd: ${expandedCwd} (must be an existing absolute directory)`);
56
+ }
57
+ const displayName = (await ask("Display name (short label for the card header): ")).trim() || basenameSafe(expandedCwd);
58
+ const scope = {
59
+ cwd: expandedCwd,
60
+ displayName,
61
+ allowedUsers: [ownerOpenId],
62
+ admins: [ownerOpenId],
63
+ defaultPermissionMode: "default",
64
+ model: null,
65
+ createdAt: Date.now(),
66
+ lastActiveAt: Date.now(),
67
+ };
68
+ const registry = ScopeRegistry.load();
69
+ registry.upsert(chatId, scope);
70
+ console.log(chalk.green(`\n✅ Bound chat ${chatId} → ${expandedCwd}\n`));
71
+ return { config, firstScope: { chatId, scope } };
72
+ }
73
+ async function runQrFlow() {
74
+ console.log("Opening QR code below. Scan with your Feishu mobile app and authorize.\n");
75
+ return new Promise((resolve, reject) => {
76
+ let printed = false;
77
+ void registerApp({
78
+ onQRCodeReady: (info) => {
79
+ if (!printed) {
80
+ qrTerminal.generate(info.url, { small: true }, (code) => {
81
+ process.stdout.write(code + "\n");
82
+ console.log(chalk.dim(`(QR expires in ${info.expireIn}s)`));
83
+ });
84
+ printed = true;
85
+ }
86
+ },
87
+ onStatusChange: (info) => {
88
+ if (info.status === "slow_down")
89
+ console.log(chalk.dim("(polling slowed — still waiting…)"));
90
+ if (info.status === "domain_switched")
91
+ console.log(chalk.dim("(domain switched)"));
92
+ },
93
+ })
94
+ .then((res) => {
95
+ resolve({
96
+ clientId: res.client_id,
97
+ clientSecret: res.client_secret,
98
+ userInfo: res.user_info,
99
+ });
100
+ })
101
+ .catch(reject);
102
+ });
103
+ }
104
+ function ask(prompt) {
105
+ return new Promise((resolve) => {
106
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
107
+ rl.question(prompt, (answer) => {
108
+ rl.close();
109
+ resolve(answer);
110
+ });
111
+ });
112
+ }
113
+ function expandUser(p) {
114
+ if (p === "~" || p.startsWith("~/"))
115
+ return homedir() + p.slice(1);
116
+ return resolvePath(p);
117
+ }
118
+ function basenameSafe(p) {
119
+ const parts = p.split(/[\\/]/);
120
+ return parts[parts.length - 1] || p;
121
+ }
package/dist/main.js CHANGED
@@ -23,12 +23,26 @@ import { loadMcpConfig } from "./mcp/config.js";
23
23
  import { McpManager } from "./mcp/manager.js";
24
24
  import { QuestionController } from "./question/index.js";
25
25
  import { buildMemoryPrompt, formatMemoryStartupResult, recordMemoryCitations, runMemoryPhase2, runMemoryStartupPipeline, startMemoryStartupTask, } from "./memory/index.js";
26
+ import { basename } from "node:path";
26
27
  async function main() {
27
28
  const args = parseArgs(process.argv.slice(2));
28
29
  if (process.argv.includes("-h") || process.argv.includes("--help")) {
29
30
  printHelp();
30
31
  process.exit(0);
31
32
  }
33
+ if (args.command === "serve") {
34
+ if (args.serveHost !== "feishu") {
35
+ console.error(chalk.red("Usage: bubble serve --feishu [--setup | --kill-old | --dry-run]"));
36
+ process.exit(2);
37
+ }
38
+ const { serveFeishu } = await import("./feishu/index.js");
39
+ await serveFeishu({
40
+ setup: args.setup,
41
+ killOld: args.killOld,
42
+ dryRun: args.dryRun,
43
+ });
44
+ return;
45
+ }
32
46
  const userConfig = new UserConfig();
33
47
  const registry = new ProviderRegistry(userConfig);
34
48
  const skillRegistry = new SkillRegistry({
@@ -156,11 +170,46 @@ async function main() {
156
170
  process.once("SIGTERM", () => { void shutdownMcp().then(() => process.exit(143)); });
157
171
  // Session management:
158
172
  // - default: always start a fresh session
159
- // - --resume: explicitly restore the latest or a named session
160
- let sessionManager = args.resume
161
- ? SessionManager.resume(args.cwd, args.sessionName)
162
- : undefined;
163
- let resumedExistingSession = !!sessionManager;
173
+ // - --resume --session <name>: restore the named session
174
+ // - --resume (no name): show interactive picker
175
+ let sessionManager;
176
+ let resumedExistingSession = false;
177
+ // Resolved before any Ink render so picker and main TUI share the same value
178
+ // and we only run OSC 11 once.
179
+ let preResolvedTheme;
180
+ if (args.resume && !args.sessionName) {
181
+ const currentSessions = SessionManager.summarizeSessionsForCwd(args.cwd);
182
+ const allSessions = SessionManager.listAllSessions();
183
+ if (currentSessions.length === 0 && allSessions.length === 0) {
184
+ console.log(chalk.dim("No previous sessions found — starting a fresh one."));
185
+ }
186
+ else {
187
+ const themeConfig = userConfig.getTheme();
188
+ if (themeConfig.mode === "auto") {
189
+ const { detectTerminalTheme } = await import("./tui-ink/detect-theme.js");
190
+ preResolvedTheme = await detectTerminalTheme();
191
+ }
192
+ else {
193
+ preResolvedTheme = themeConfig.mode;
194
+ }
195
+ const { runSessionPicker } = await import("./tui-ink/run-session-picker.js");
196
+ const picked = await runSessionPicker({
197
+ currentCwd: args.cwd,
198
+ currentSessions,
199
+ allSessions,
200
+ resolvedTheme: preResolvedTheme,
201
+ themeOverrides: themeConfig.overrides,
202
+ });
203
+ if (picked) {
204
+ sessionManager = new SessionManager(picked);
205
+ resumedExistingSession = true;
206
+ }
207
+ }
208
+ }
209
+ else if (args.resume) {
210
+ sessionManager = SessionManager.resume(args.cwd, args.sessionName);
211
+ resumedExistingSession = !!sessionManager;
212
+ }
164
213
  if (!sessionManager) {
165
214
  sessionManager = args.sessionName && !args.resume
166
215
  ? SessionManager.create(args.cwd, args.sessionName)
@@ -350,8 +399,20 @@ async function main() {
350
399
  console.log();
351
400
  return;
352
401
  }
353
- const tuiRuntime = process.env.BUBBLE_TUI === "opentui" ? "opentui" : "ink";
354
402
  const themeConfig = userConfig.getTheme();
403
+ let detectedTheme = "dark";
404
+ if (preResolvedTheme) {
405
+ detectedTheme = preResolvedTheme;
406
+ }
407
+ else if (themeConfig.mode === "auto") {
408
+ // Probe before either TUI runtime owns stdin. OSC 11 needs raw mode, and
409
+ // runtime renderers can consume the reply before startup code sees it.
410
+ const { detectTerminalTheme } = await import("./tui-ink/detect-theme.js");
411
+ detectedTheme = await detectTerminalTheme();
412
+ }
413
+ else {
414
+ detectedTheme = themeConfig.mode;
415
+ }
355
416
  const commonOptions = {
356
417
  sessionManager,
357
418
  createProvider,
@@ -369,29 +430,17 @@ async function main() {
369
430
  runMemorySummary,
370
431
  runMemoryRefresh,
371
432
  };
372
- if (tuiRuntime === "opentui") {
373
- const { runTui } = await import("./tui/run.js");
374
- await runTui(agent, args, {
375
- ...commonOptions,
376
- theme: themeConfig.overrides,
377
- });
378
- }
379
- else {
380
- // Probe the terminal background BEFORE Ink takes over stdin. OSC 11
381
- // needs raw mode, and once Ink owns stdin the reply never reaches us.
382
- let detectedTheme = "dark";
383
- if (themeConfig.mode === "auto") {
384
- const { detectTerminalTheme } = await import("./tui-ink/detect-theme.js");
385
- detectedTheme = await detectTerminalTheme();
386
- }
387
- const { runTui } = await import("./tui-ink/run.js");
388
- await runTui(agent, args, {
389
- ...commonOptions,
390
- themeMode: themeConfig.mode,
391
- themeOverrides: themeConfig.overrides,
392
- detectedTheme,
393
- onThemeModeChange: (mode) => userConfig.setThemeMode(mode),
394
- });
433
+ const { runTui } = await import("./tui-ink/run.js");
434
+ await runTui(agent, args, {
435
+ ...commonOptions,
436
+ themeMode: themeConfig.mode,
437
+ themeOverrides: themeConfig.overrides,
438
+ detectedTheme,
439
+ onThemeModeChange: (mode) => userConfig.setThemeMode(mode),
440
+ });
441
+ if (sessionManager) {
442
+ const sessionName = basename(sessionManager.getSessionFile());
443
+ console.log(chalk.dim(`To resume: bubble --resume (or --resume --session ${sessionName})`));
395
444
  }
396
445
  }
397
446
  finally {