@bubblebrain-ai/bubble 0.0.46 → 0.0.48

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 (95) hide show
  1. package/dist/agent/child-runner.js +11 -0
  2. package/dist/agent/internal-reminder-sanitizer.d.ts +7 -0
  3. package/dist/agent/internal-reminder-sanitizer.js +13 -0
  4. package/dist/agent/subagent/router.d.ts +65 -0
  5. package/dist/agent/subagent/router.js +198 -0
  6. package/dist/agent/subagent/runtime.d.ts +203 -0
  7. package/dist/agent/subagent/runtime.js +789 -0
  8. package/dist/agent/subagent-control.d.ts +5 -0
  9. package/dist/agent/subagent-store.d.ts +18 -2
  10. package/dist/agent/subagent-store.js +32 -1
  11. package/dist/agent/tool-intent.d.ts +10 -0
  12. package/dist/agent/tool-intent.js +18 -0
  13. package/dist/agent/tool-visibility.d.ts +10 -0
  14. package/dist/agent/tool-visibility.js +6 -0
  15. package/dist/agent/workflow/runs.d.ts +72 -0
  16. package/dist/agent/workflow/runs.js +157 -0
  17. package/dist/agent.d.ts +78 -96
  18. package/dist/agent.js +266 -1072
  19. package/dist/context/compact-llm.js +14 -5
  20. package/dist/context/compact.d.ts +9 -0
  21. package/dist/context/compact.js +36 -10
  22. package/dist/context/compaction-files.d.ts +44 -0
  23. package/dist/context/compaction-files.js +148 -0
  24. package/dist/context/llm-compactor.js +24 -5
  25. package/dist/feishu/agent-host/run-driver.js +3 -0
  26. package/dist/feishu/serve.js +13 -9
  27. package/dist/goal/tools.js +12 -3
  28. package/dist/main.js +24 -19
  29. package/dist/model-catalog.js +36 -2
  30. package/dist/model-config.d.ts +2 -0
  31. package/dist/model-data.generated.d.ts +16 -0
  32. package/dist/model-data.generated.js +275 -0
  33. package/dist/model-pricing.d.ts +11 -7
  34. package/dist/model-pricing.js +0 -0
  35. package/dist/oauth/grok.d.ts +1 -1
  36. package/dist/oauth/grok.js +5 -2
  37. package/dist/oauth/storage.d.ts +9 -2
  38. package/dist/oauth/storage.js +16 -9
  39. package/dist/orchestrator/default-hooks.d.ts +20 -0
  40. package/dist/orchestrator/default-hooks.js +47 -295
  41. package/dist/orchestrator/hooks.d.ts +6 -39
  42. package/dist/permission/mode.js +1 -1
  43. package/dist/prompt/reminders.d.ts +9 -35
  44. package/dist/prompt/reminders.js +30 -136
  45. package/dist/prompt/task-reminders.d.ts +0 -31
  46. package/dist/prompt/task-reminders.js +0 -87
  47. package/dist/provider-ai-sdk.d.ts +2 -0
  48. package/dist/provider-ai-sdk.js +1 -0
  49. package/dist/provider-anthropic.d.ts +2 -0
  50. package/dist/provider-anthropic.js +3 -1
  51. package/dist/provider-ark-responses.d.ts +2 -0
  52. package/dist/provider-ark-responses.js +1 -0
  53. package/dist/provider-grok.d.ts +26 -0
  54. package/dist/provider-grok.js +39 -0
  55. package/dist/provider-registry.d.ts +14 -1
  56. package/dist/provider-registry.js +131 -3
  57. package/dist/provider-transform.js +37 -1
  58. package/dist/provider.d.ts +7 -0
  59. package/dist/provider.js +13 -1
  60. package/dist/sdk/index.d.ts +152 -0
  61. package/dist/sdk/index.js +400 -0
  62. package/dist/session.js +9 -1
  63. package/dist/slash-commands/commands.js +33 -19
  64. package/dist/tools/exit-plan-mode.js +8 -0
  65. package/dist/tui/detect-theme.d.ts +1 -0
  66. package/dist/tui/detect-theme.js +11 -5
  67. package/dist/tui-ink/app.d.ts +2 -6
  68. package/dist/tui-ink/app.js +11 -18
  69. package/dist/tui-ink/display-history.d.ts +0 -2
  70. package/dist/tui-ink/display-history.js +0 -52
  71. package/dist/tui-ink/footer.js +2 -2
  72. package/dist/tui-ink/input-box.js +18 -6
  73. package/dist/tui-ink/message-list.js +62 -23
  74. package/dist/tui-ink/run.js +10 -5
  75. package/dist/tui-ink/sync-output.d.ts +17 -0
  76. package/dist/tui-ink/sync-output.js +35 -0
  77. package/dist/tui-ink/theme.d.ts +0 -1
  78. package/dist/tui-ink/theme.js +0 -2
  79. package/dist/tui-ink/trace-groups.js +1 -1
  80. package/dist/types.d.ts +21 -1
  81. package/package.json +16 -2
  82. package/dist/agent/discovery-barrier.d.ts +0 -21
  83. package/dist/agent/discovery-barrier.js +0 -173
  84. package/dist/agent/evidence-tracker.d.ts +0 -15
  85. package/dist/agent/evidence-tracker.js +0 -93
  86. package/dist/agent/execution-governor.d.ts +0 -36
  87. package/dist/agent/execution-governor.js +0 -218
  88. package/dist/agent/task-classifier.d.ts +0 -3
  89. package/dist/agent/task-classifier.js +0 -114
  90. package/dist/agent/task-size.d.ts +0 -9
  91. package/dist/agent/task-size.js +0 -33
  92. package/dist/agent/tool-arbiter.d.ts +0 -7
  93. package/dist/agent/tool-arbiter.js +0 -33
  94. package/dist/orchestrator/workflow.d.ts +0 -10
  95. package/dist/orchestrator/workflow.js +0 -22
@@ -0,0 +1,400 @@
1
+ /**
2
+ * Bubble SDK — the programmatic embedding surface for external hosts.
3
+ *
4
+ * Two layers:
5
+ * 1. `BubbleSdk` — a high-level facade that owns config/provider resolution and
6
+ * assembles a full agent turn (tools, approvals, questions, plan mode, MCP,
7
+ * skills, hooks, session persistence) behind a single `runTurn()` async
8
+ * iterator of `AgentEvent`s. This is what a host like an Electron app should
9
+ * consume: wire the three interaction callbacks (approval / question / plan)
10
+ * to its own UI and map the event stream to its own message format.
11
+ * 2. Curated re-exports of the underlying building blocks, for hosts that need
12
+ * to assemble a custom loop the facade doesn't cover.
13
+ *
14
+ * Runs under plain Node (>=20); nothing here touches the TUI layer.
15
+ */
16
+ import os from "node:os";
17
+ import { rmSync } from "node:fs";
18
+ import { randomUUID } from "node:crypto";
19
+ import { Agent } from "../agent.js";
20
+ import { SessionManager } from "../session.js";
21
+ import { PermissionAwareApprovalController } from "../approval/controller.js";
22
+ import { BashAllowlist } from "../approval/session-cache.js";
23
+ import { createAllTools, buildToolPromptOptions } from "../tools/index.js";
24
+ import { buildSystemPrompt } from "../system-prompt.js";
25
+ import { FileStateTracker } from "../tools/file-state.js";
26
+ import { BudgetLedger } from "../agent/budget-ledger.js";
27
+ import { UserConfig } from "../config.js";
28
+ import { ProviderRegistry, encodeModel, decodeModel, displayModel } from "../provider-registry.js";
29
+ import { createProviderInstance } from "../provider.js";
30
+ import { getDefaultThinkingLevel } from "../variant/variant-resolver.js";
31
+ import { QuestionController } from "../question/controller.js";
32
+ import { SkillRegistry } from "../skills/registry.js";
33
+ import { parseSkillInvocation } from "../skills/invocation.js";
34
+ import { GoalStore } from "../goal/store.js";
35
+ import { McpManager } from "../mcp/manager.js";
36
+ import { loadMcpConfig } from "../mcp/config.js";
37
+ import { ExternalHookController } from "../hooks/controller.js";
38
+ import { buildMemoryPrompt } from "../memory/store.js";
39
+ import { calculateUsageCost } from "../model-pricing.js";
40
+ import { purgeUnsafeMemorySources } from "../memory/session-policy.js";
41
+ import { recordMemoryCitations } from "../memory/usage.js";
42
+ // ── Facade ─────────────────────────────────────────────────────────────────
43
+ export class BubbleSdk {
44
+ userConfig = new UserConfig();
45
+ registry = new ProviderRegistry(this.userConfig);
46
+ defaultCwd;
47
+ mcpEnabled;
48
+ cwdBySession = new Map();
49
+ bashAllowlists = new Map();
50
+ activeTurns = new Map();
51
+ mcpToolsByCwd = new Map();
52
+ /** id -> {cwd,file} rebuilt from disk so sessions survive host restarts. */
53
+ sessionIndex = new Map();
54
+ constructor(options = {}) {
55
+ this.defaultCwd = options.defaultCwd || process.env.BUBBLE_CWD || os.homedir();
56
+ this.mcpEnabled = options.mcp !== false;
57
+ }
58
+ // ── Sessions ─────────────────────────────────────────────────────────────
59
+ listSessions() {
60
+ const sessions = SessionManager.listAllSessions();
61
+ this.sessionIndex = new Map(sessions.map((s) => [s.name, { cwd: s.cwd ?? s.cwdLabel ?? this.defaultCwd, file: s.file }]));
62
+ return sessions;
63
+ }
64
+ createSession(options = {}) {
65
+ const cwd = options.cwd || this.defaultCwd;
66
+ const id = options.id || `sdk-${Date.now().toString(36)}-${randomUUID().slice(0, 8)}`;
67
+ SessionManager.create(cwd, sessionFileName(id));
68
+ this.cwdBySession.set(id, cwd);
69
+ return { id, cwd };
70
+ }
71
+ getHistory(sessionId) {
72
+ return this.resolveSession(sessionId)?.manager.getMessages() ?? [];
73
+ }
74
+ deleteSession(sessionId) {
75
+ const resolved = this.resolveSession(sessionId);
76
+ if (resolved)
77
+ rmSync(resolved.manager.getSessionFile(), { force: true });
78
+ this.cwdBySession.delete(sessionId);
79
+ this.bashAllowlists.delete(sessionId);
80
+ this.sessionIndex.delete(sessionId);
81
+ }
82
+ /** Abort the in-flight turn of a session, if any. */
83
+ stop(sessionId) {
84
+ this.activeTurns.get(sessionId)?.abort();
85
+ }
86
+ // ── Discovery (composer pickers) ─────────────────────────────────────────
87
+ listSkills(cwd) {
88
+ const registry = new SkillRegistry({
89
+ cwd: cwd || this.defaultCwd,
90
+ skillPaths: this.userConfig.getSkillPaths(),
91
+ });
92
+ return registry.summaries();
93
+ }
94
+ /** Configured providers + default model, for a host's model picker. */
95
+ getModelConfig() {
96
+ const configured = this.registry.getConfigured().filter((p) => p.enabled);
97
+ return {
98
+ defaultProviderId: this.registry.getDefault()?.id ?? "",
99
+ defaultModel: this.userConfig.getDefaultModel() ?? "",
100
+ providers: configured.map((p) => ({ id: p.id, baseURL: p.baseURL, hasApiKey: Boolean(p.apiKey) })),
101
+ };
102
+ }
103
+ // ── The turn ─────────────────────────────────────────────────────────────
104
+ async *runTurn(sessionId, options) {
105
+ const resolved = this.resolveSession(sessionId);
106
+ if (!resolved)
107
+ throw new Error(`Unknown session: ${sessionId}`);
108
+ const { manager: session, cwd } = resolved;
109
+ const mode = options.mode ?? "default";
110
+ const abort = new AbortController();
111
+ if (options.signal) {
112
+ if (options.signal.aborted)
113
+ abort.abort();
114
+ else
115
+ options.signal.addEventListener("abort", () => abort.abort(), { once: true });
116
+ }
117
+ this.activeTurns.set(sessionId, abort);
118
+ let agentRef;
119
+ const hookController = new ExternalHookController({ cwd, sessionId });
120
+ // Settles as reject the moment the turn aborts, so a tool blocked on a
121
+ // host approval (or question) can never hang the abort path.
122
+ const abortedDecision = new Promise((resolve) => {
123
+ const decide = () => resolve({ action: "reject", feedback: "Turn aborted" });
124
+ if (abort.signal.aborted)
125
+ decide();
126
+ else
127
+ abort.signal.addEventListener("abort", decide, { once: true });
128
+ });
129
+ const approvalController = new PermissionAwareApprovalController({
130
+ getMode: () => agentRef?.mode ?? mode,
131
+ handlerRef: {
132
+ current: (req) => options.onApproval
133
+ ? Promise.race([options.onApproval(req), abortedDecision])
134
+ : Promise.resolve({
135
+ action: "reject",
136
+ feedback: "Host provided no approval handler",
137
+ }),
138
+ },
139
+ bashAllowlist: this.bashAllowlistFor(sessionId),
140
+ cwd,
141
+ externalHooks: hookController,
142
+ });
143
+ const fileStateTracker = new FileStateTracker(cwd);
144
+ const planController = {
145
+ getMode: () => agentRef?.mode ?? mode,
146
+ requestApproval: async (plan) => {
147
+ const approved = options.onPlanApproval ? await options.onPlanApproval(plan) : false;
148
+ if (approved) {
149
+ agentRef?.setMode("default");
150
+ return { action: "approve", plan };
151
+ }
152
+ return { action: "reject", reason: "Plan rejected by host" };
153
+ },
154
+ setMode: (m) => agentRef?.setMode(m),
155
+ };
156
+ const questionController = new QuestionController();
157
+ const unsubscribeQuestions = questionController.subscribe((event) => {
158
+ if (event.type !== "asked")
159
+ return;
160
+ const request = event.request;
161
+ if (!options.onQuestion) {
162
+ questionController.reject(request.id);
163
+ return;
164
+ }
165
+ options.onQuestion(request).then((answers) => answers ? questionController.reply(request.id, answers) : questionController.reject(request.id), () => questionController.reject(request.id));
166
+ });
167
+ abort.signal.addEventListener("abort", () => questionController.rejectAll(), { once: true });
168
+ try {
169
+ const skillRegistry = new SkillRegistry({ cwd, skillPaths: this.userConfig.getSkillPaths() });
170
+ const tools = createAllTools(cwd, skillRegistry, {
171
+ approvalController,
172
+ fileStateTracker,
173
+ planController,
174
+ todoStore: {
175
+ getTodos: () => agentRef?.getTodos() ?? [],
176
+ setTodos: (todos) => agentRef?.setTodos(todos),
177
+ },
178
+ questionController,
179
+ goalStore: new GoalStore(),
180
+ checkpoints: () => session.getCheckpoints(),
181
+ });
182
+ tools.push(...(await this.mcpToolsFor(cwd)));
183
+ const promptCacheKey = session.getOrCreatePromptCacheKey();
184
+ session.updateMetadata({ cwd }); // recoverable by cwd after host restart
185
+ const { provider, providerId, model } = this.resolveProvider(promptCacheKey, options.model);
186
+ const thinkingLevel = options.thinkingLevel ??
187
+ this.userConfig.getDefaultThinkingLevel() ??
188
+ getDefaultThinkingLevel(providerId, decodeModel(model).modelId);
189
+ // Same ordering as the TUI: the provenance gate must run before memories
190
+ // enter the system prompt.
191
+ purgeUnsafeMemorySources(cwd);
192
+ const memoryPrompt = buildMemoryPrompt(cwd);
193
+ const builtSystemPrompt = buildSystemPrompt({
194
+ agentName: "Bubble",
195
+ configuredProvider: providerId || "none",
196
+ configuredModel: model ? displayModel(model) : "none",
197
+ configuredModelId: model || "none",
198
+ thinkingLevel,
199
+ mode,
200
+ workingDir: cwd,
201
+ ...buildToolPromptOptions(tools.filter((t) => !t.deferred)),
202
+ memoryPrompt,
203
+ });
204
+ const systemPrompt = options.appendSystemPrompt
205
+ ? `${builtSystemPrompt}\n\n${options.appendSystemPrompt.trim()}`
206
+ : builtSystemPrompt;
207
+ const agent = new Agent({
208
+ provider,
209
+ providerId,
210
+ model,
211
+ sessionID: session.getSessionFile(),
212
+ tools,
213
+ systemPrompt,
214
+ temperature: 0.2,
215
+ thinkingLevel,
216
+ mode,
217
+ todos: session.getTodos(),
218
+ budgetLedger: new BudgetLedger(),
219
+ fileStateTracker,
220
+ skills: skillRegistry.summaries(),
221
+ memoryPrompt,
222
+ externalHooks: hookController,
223
+ onMessageAppend: (message) => {
224
+ if (message.role === "system" || message.role === "meta")
225
+ return;
226
+ session.appendMessage(message);
227
+ if (message.role === "assistant")
228
+ recordMemoryCitations(cwd, message.content);
229
+ },
230
+ onCompactionApplied: (summary) => {
231
+ session.applyLLMCompaction(summary);
232
+ },
233
+ onTodosUpdate: (todos) => session.appendTodosSnapshot(todos),
234
+ onModeUpdate: (m) => session.appendMarker("mode_switch", m),
235
+ });
236
+ agentRef = agent;
237
+ const history = session.getMessages();
238
+ if (history.length > 0) {
239
+ agent.messages = [{ role: "system", content: systemPrompt }, ...history];
240
+ }
241
+ options.onStart?.({
242
+ providerId,
243
+ model,
244
+ thinkingLevel,
245
+ mode,
246
+ tools: tools.map((t) => t.name),
247
+ skills: skillRegistry.summaries().map((s) => s.name),
248
+ });
249
+ // Match the TUI: a leading "/<skill-name> <task>" becomes an explicit
250
+ // load-this-skill instruction, so invocation never depends on the model
251
+ // recognizing the slash syntax on its own.
252
+ const prompt = rewriteSkillInvocationPrompt(options.prompt, skillRegistry);
253
+ const bareModelId = decodeModel(model).modelId;
254
+ for await (const event of agent.run(prompt, cwd, { abortSignal: abort.signal })) {
255
+ yield attachTurnCost(event, providerId, bareModelId);
256
+ if (abort.signal.aborted)
257
+ break;
258
+ }
259
+ }
260
+ finally {
261
+ // Also reached via generator.return() when the host stops consuming
262
+ // early: aborting here unblocks any tool still awaiting an approval.
263
+ abort.abort();
264
+ unsubscribeQuestions();
265
+ questionController.rejectAll();
266
+ if (this.activeTurns.get(sessionId) === abort)
267
+ this.activeTurns.delete(sessionId);
268
+ }
269
+ }
270
+ // ── Internals ────────────────────────────────────────────────────────────
271
+ bashAllowlistFor(sessionId) {
272
+ let allowlist = this.bashAllowlists.get(sessionId);
273
+ if (!allowlist) {
274
+ allowlist = new BashAllowlist();
275
+ this.bashAllowlists.set(sessionId, allowlist);
276
+ }
277
+ return allowlist;
278
+ }
279
+ /** MCP servers are started lazily, once per cwd (McpManager has no stop). */
280
+ mcpToolsFor(cwd) {
281
+ if (!this.mcpEnabled)
282
+ return Promise.resolve([]);
283
+ let cached = this.mcpToolsByCwd.get(cwd);
284
+ if (!cached) {
285
+ cached = (async () => {
286
+ const loaded = loadMcpConfig({ cwd });
287
+ if (loaded.servers.length === 0)
288
+ return [];
289
+ const manager = new McpManager({ servers: loaded.servers });
290
+ await manager.start();
291
+ return manager.getToolEntries();
292
+ })().catch(() => []);
293
+ this.mcpToolsByCwd.set(cwd, cached);
294
+ }
295
+ return cached;
296
+ }
297
+ resolveSession(sessionId) {
298
+ const memCwd = this.cwdBySession.get(sessionId);
299
+ if (memCwd) {
300
+ // create() only resolves the path and loads iff the file exists, so this
301
+ // covers both fresh (lazily persisted, not yet on disk) and resumed sessions.
302
+ return { manager: SessionManager.create(memCwd, sessionFileName(sessionId)), cwd: memCwd };
303
+ }
304
+ if (this.sessionIndex.size === 0)
305
+ this.listSessions();
306
+ const entry = this.sessionIndex.get(sessionId);
307
+ if (!entry)
308
+ return undefined;
309
+ this.cwdBySession.set(sessionId, entry.cwd);
310
+ return { manager: new SessionManager(entry.file), cwd: entry.cwd };
311
+ }
312
+ resolveProvider(promptCacheKey, explicitModel) {
313
+ const configuredModel = explicitModel || this.userConfig.getDefaultModel();
314
+ const defaultProvider = this.registry.getDefault();
315
+ const fallbackProviderId = defaultProvider?.id ?? "";
316
+ const normalized = configuredModel
317
+ ? configuredModel.includes(":")
318
+ ? configuredModel
319
+ : fallbackProviderId
320
+ ? encodeModel(fallbackProviderId, configuredModel)
321
+ : ""
322
+ : "";
323
+ const { providerId: effId, modelId: effModelId } = normalized
324
+ ? decodeModel(normalized)
325
+ : { providerId: undefined, modelId: "" };
326
+ const activeProviderId = effId || fallbackProviderId;
327
+ const target = this.registry.getConfigured().find((p) => p.id === activeProviderId) || defaultProvider;
328
+ if (!target?.apiKey) {
329
+ throw new Error("No provider with an API key configured — run the Bubble CLI once to set one up");
330
+ }
331
+ const activeModel = effModelId ? encodeModel(activeProviderId, effModelId) : "";
332
+ const provider = createProviderInstance({
333
+ providerId: activeProviderId,
334
+ apiKey: target.apiKey,
335
+ baseURL: target.baseURL,
336
+ promptCacheKey,
337
+ protocol: target.protocol,
338
+ headers: target.headers,
339
+ });
340
+ return { provider, providerId: activeProviderId, model: activeModel };
341
+ }
342
+ }
343
+ function sessionFileName(sessionId) {
344
+ return sessionId.endsWith(".jsonl") ? sessionId : `${sessionId}.jsonl`;
345
+ }
346
+ /**
347
+ * Attach a `cost` field to turn_end events that carry usage, priced via the
348
+ * static pricing table. Pricing is an accounting concern, so it rides along
349
+ * here in the SDK rather than in the agent core. Multi-step turns emit several
350
+ * turn_end events; each cost covers only that step's usage, so hosts sum them.
351
+ * Events for unpriced models (or without usage) pass through untouched.
352
+ */
353
+ export function attachTurnCost(event, providerId, modelId) {
354
+ if (event.type !== "turn_end" || !event.usage)
355
+ return event;
356
+ const cost = calculateUsageCost(providerId, modelId, event.usage);
357
+ return cost ? { ...event, cost } : event;
358
+ }
359
+ /**
360
+ * If the prompt (or its first text part) is a "/<skill-name> <task>" skill
361
+ * invocation, rewrite it into the same explicit execution prompt the TUI
362
+ * sends. Non-matching prompts pass through untouched.
363
+ */
364
+ export function rewriteSkillInvocationPrompt(prompt, registry) {
365
+ if (typeof prompt === "string") {
366
+ return parseSkillInvocation(prompt, registry)?.actualPrompt ?? prompt;
367
+ }
368
+ const index = prompt.findIndex((part) => part.type === "text");
369
+ if (index === -1)
370
+ return prompt;
371
+ const part = prompt[index];
372
+ const rewritten = parseSkillInvocation(part.text, registry)?.actualPrompt;
373
+ if (!rewritten)
374
+ return prompt;
375
+ const next = prompt.slice();
376
+ next[index] = { ...part, text: rewritten };
377
+ return next;
378
+ }
379
+ // ── Building blocks (escape hatch for custom hosts) ────────────────────────
380
+ export { Agent, AgentAbortError } from "../agent.js";
381
+ export { SessionManager, getSessionsDir, } from "../session.js";
382
+ export { PermissionAwareApprovalController } from "../approval/controller.js";
383
+ export { BashAllowlist } from "../approval/session-cache.js";
384
+ export { createAllTools, buildToolPromptOptions } from "../tools/index.js";
385
+ export { buildSystemPrompt } from "../system-prompt.js";
386
+ export { FileStateTracker } from "../tools/file-state.js";
387
+ export { BudgetLedger } from "../agent/budget-ledger.js";
388
+ export { UserConfig } from "../config.js";
389
+ export { ProviderRegistry, encodeModel, decodeModel, displayModel, } from "../provider-registry.js";
390
+ export { createProviderInstance } from "../provider.js";
391
+ export { getDefaultThinkingLevel } from "../variant/variant-resolver.js";
392
+ export { QuestionController, QuestionRejectedError, } from "../question/controller.js";
393
+ export { SkillRegistry } from "../skills/registry.js";
394
+ export { parseSkillInvocation } from "../skills/invocation.js";
395
+ export { GoalStore } from "../goal/store.js";
396
+ export { McpManager } from "../mcp/manager.js";
397
+ export { loadMcpConfig } from "../mcp/config.js";
398
+ export { ExternalHookController } from "../hooks/controller.js";
399
+ export { calculateUsageCost, getModelPricing, } from "../model-pricing.js";
400
+ export { registry as slashCommandRegistry } from "../slash-commands/index.js";
package/dist/session.js CHANGED
@@ -4,6 +4,7 @@
4
4
  import { randomUUID } from "node:crypto";
5
5
  import { mkdirSync, appendFileSync, existsSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
6
6
  import { basename, dirname, join } from "node:path";
7
+ import { isInternalBlockOnlyContent } from "./agent/internal-reminder-sanitizer.js";
7
8
  import { getBubbleHome } from "./bubble-home.js";
8
9
  import { CheckpointStore } from "./checkpoints.js";
9
10
  import { buildCompactedEntries, compactSessionEntries, planOldMessages, planSessionCompaction, } from "./context/compact.js";
@@ -237,6 +238,10 @@ export class SessionManager {
237
238
  const entry = entries[i];
238
239
  if (entry.type !== "user_message")
239
240
  continue;
241
+ // Harness-injected turns (goal kicks, task wakes) are internal blocks —
242
+ // not rewind anchors, and their markup must never render in the picker.
243
+ if (isInternalBlockOnlyContent(entry.message.content))
244
+ continue;
240
245
  const text = messageText(entry.message);
241
246
  turns.push({
242
247
  id: entry.id,
@@ -354,8 +359,11 @@ function firstUserEntryAfterLatestClear(entries) {
354
359
  const startIndex = latestClearIndex(entries) + 1;
355
360
  for (let i = startIndex; i < entries.length; i++) {
356
361
  const entry = entries[i];
357
- if (entry.type === "user_message")
362
+ // Skip harness-injected internal blocks (goal kicks, task wakes): they
363
+ // must not become the /resume preview or the deterministic title.
364
+ if (entry.type === "user_message" && !isInternalBlockOnlyContent(entry.message.content)) {
358
365
  return entry;
366
+ }
359
367
  }
360
368
  return undefined;
361
369
  }
@@ -987,7 +987,7 @@ const builtinSlashCommandEntries = [
987
987
  },
988
988
  {
989
989
  name: "mcp",
990
- description: "Manage MCP servers. Usage: /mcp [list|reconnect <name>]",
990
+ description: "Manage MCP servers. Usage: /mcp [list|tools <name>|reconnect <name>]",
991
991
  async handler(args, ctx) {
992
992
  const tokens = args.trim().split(/\s+/).filter(Boolean);
993
993
  const sub = tokens[0] ?? "list";
@@ -1009,46 +1009,60 @@ const builtinSlashCommandEntries = [
1009
1009
  }
1010
1010
  return `${name}: ${state.status.kind}`;
1011
1011
  }
1012
+ if (sub === "tools") {
1013
+ const name = tokens[1];
1014
+ if (!name)
1015
+ return "Usage: /mcp tools <server-name>";
1016
+ const state = ctx.mcpManager.getStates().find((s) => s.name === name);
1017
+ if (!state)
1018
+ return `Unknown MCP server: ${name}`;
1019
+ if (state.status.kind !== "connected") {
1020
+ return `${name} is not connected — no tools to list. Try /mcp reconnect ${name}.`;
1021
+ }
1022
+ const lines = [`Tools from ${name} (${state.status.tools.length}):`, ""];
1023
+ for (const tool of state.status.tools) {
1024
+ lines.push(`- \`${tool.name}\`${tool.description ? ` — ${tool.description.replace(/\s+/g, " ").slice(0, 100)}` : ""}`);
1025
+ }
1026
+ return lines.join("\n");
1027
+ }
1012
1028
  if (sub !== "list" && sub !== "") {
1013
- return `Unknown /mcp subcommand "${sub}". Use /mcp list or /mcp reconnect <name>.`;
1029
+ return `Unknown /mcp subcommand "${sub}". Use /mcp list, /mcp tools <name>, or /mcp reconnect <name>.`;
1014
1030
  }
1015
1031
  const states = ctx.mcpManager.getStates();
1016
1032
  if (states.length === 0) {
1017
1033
  return "No MCP servers configured. Add entries under `mcpServers` in ~/.bubble/settings.json or <cwd>/.bubble/settings.json.";
1018
1034
  }
1035
+ // Rendered as markdown in the TUI: each server is its own paragraph,
1036
+ // failures are bold + uppercase so they stand apart from healthy rows,
1037
+ // and tool lists stay collapsed behind /mcp tools <name>.
1019
1038
  const lines = ["MCP servers:"];
1020
1039
  for (const state of states) {
1021
- const transport = state.config.type;
1022
- const scope = state.scope;
1040
+ const meta = `${state.scope}/${state.config.type}`;
1041
+ lines.push("");
1023
1042
  if (state.status.kind === "connected") {
1024
- const info = state.status.serverInfo ? ` ${state.status.serverInfo.name}@${state.status.serverInfo.version}` : "";
1043
+ const info = state.status.serverInfo ? ` · ${state.status.serverInfo.name}@${state.status.serverInfo.version}` : "";
1025
1044
  const tn = state.status.tools.length;
1026
1045
  const pn = state.status.prompts.length;
1027
1046
  const counts = [`${tn} tool${tn === 1 ? "" : "s"}`];
1028
1047
  if (pn > 0)
1029
1048
  counts.push(`${pn} prompt${pn === 1 ? "" : "s"}`);
1030
- lines.push(` ✔ ${state.name} [${scope}/${transport}]${info} — ${counts.join(", ")}`);
1031
- for (const tool of state.status.tools) {
1032
- lines.push(` · ${tool.name}${tool.description ? ` — ${tool.description.replace(/\s+/g, " ").slice(0, 80)}` : ""}`);
1033
- }
1049
+ lines.push(`✔ ${state.name} — connected · ${counts.join(" · ")} (${meta}${info})`);
1034
1050
  if (pn > 0) {
1035
- lines.push(` prompts (invoke as /<name>):`);
1036
- for (const p of state.status.prompts) {
1037
- const argSig = p.arguments?.length
1038
- ? ` <${p.arguments.map((a) => (a.required ? a.name : `${a.name}?`)).join("> <")}>`
1039
- : "";
1040
- const cmdName = normalizeNameForMCP(p.name);
1041
- lines.push(` · /${cmdName}${argSig}${p.description ? ` — ${p.description.replace(/\s+/g, " ").slice(0, 70)}` : ""}`);
1042
- }
1051
+ const prompts = state.status.prompts.map((p) => `/${normalizeNameForMCP(p.name)}`);
1052
+ lines.push(` prompts: ${prompts.join(", ")}`);
1043
1053
  }
1044
1054
  }
1045
1055
  else if (state.status.kind === "failed") {
1046
- lines.push(` ✘ ${state.name} [${scope}/${transport}] — ${state.status.error}`);
1056
+ lines.push(`**✘ ${state.name} — UNABLE TO CONNECT** (${meta})`);
1057
+ lines.push(` ${state.status.error.replace(/\s+/g, " ").slice(0, 200)}`);
1058
+ lines.push(` retry: /mcp reconnect ${state.name}`);
1047
1059
  }
1048
1060
  else {
1049
- lines.push(` ○ ${state.name} [${scope}/${transport}] — disabled`);
1061
+ lines.push(`○ ${state.name} — disabled (${meta})`);
1050
1062
  }
1051
1063
  }
1064
+ lines.push("");
1065
+ lines.push("Details: /mcp tools <name> · /mcp reconnect <name>");
1052
1066
  return lines.join("\n");
1053
1067
  },
1054
1068
  },
@@ -11,6 +11,14 @@ export function createExitPlanModeTool(controller) {
11
11
  readOnly: true,
12
12
  effect: "read",
13
13
  requiresApproval: true,
14
+ // Outside plan mode the tool is not just useless, it is bait: models call
15
+ // it to "wrap up" an ordinary turn despite the description below saying
16
+ // not to (same failure seen with update_goal — wording loses to the
17
+ // trained close-out prior). Gating it out of the provider's function list
18
+ // removes the affordance; the predicate re-runs every model call, so
19
+ // entering plan mode surfaces it immediately. The runtime no-op in
20
+ // agent.ts stays as a backstop for a mode flip mid-turn.
21
+ enabled: () => controller.getMode() === "plan",
14
22
  description: "ONLY call this tool when the harness has told you via a runtime reminder that plan mode is ACTIVE. " +
15
23
  "Do NOT call it during ordinary work — in default mode you should just use the regular tools directly. " +
16
24
  "In plan mode: after investigating, call this with a concrete step-by-step plan so the user can approve, edit, or reject. " +
@@ -1,3 +1,4 @@
1
1
  export type ResolvedTheme = "light" | "dark";
2
2
  export declare function detectTerminalTheme(timeoutMs?: number): Promise<ResolvedTheme>;
3
+ export declare function parseColorFgBg(value: string | undefined): ResolvedTheme | null;
3
4
  export declare function themeFromMacOsAppearance(output: string | null | undefined): ResolvedTheme;
@@ -1,19 +1,25 @@
1
1
  import { execFileSync } from "node:child_process";
2
- export async function detectTerminalTheme(timeoutMs = 150) {
3
- const fromEnv = parseColorFgBg(process.env.COLORFGBG);
4
- if (fromEnv)
5
- return fromEnv;
2
+ export async function detectTerminalTheme(timeoutMs = 300) {
3
+ // OSC 11 asks the terminal for its actual background color right now — the
4
+ // only signal that tracks a theme switched after the shell started. The
5
+ // launch-time signals below are tie-breakers for terminals that don't
6
+ // answer: COLORFGBG is frozen at shell startup (and propagated stale by
7
+ // tmux/ssh), and the OS appearance describes the system, not the terminal
8
+ // profile. Ordering OSC 11 first is what keeps "auto" matching reality.
6
9
  if (process.stdout.isTTY && process.stdin.isTTY) {
7
10
  const fromOsc = await queryOsc11(timeoutMs);
8
11
  if (fromOsc)
9
12
  return fromOsc;
10
13
  }
14
+ const fromEnv = parseColorFgBg(process.env.COLORFGBG);
15
+ if (fromEnv)
16
+ return fromEnv;
11
17
  const fromOs = detectOsAppearanceTheme();
12
18
  if (fromOs)
13
19
  return fromOs;
14
20
  return "dark";
15
21
  }
16
- function parseColorFgBg(value) {
22
+ export function parseColorFgBg(value) {
17
23
  if (!value)
18
24
  return null;
19
25
  const parts = value.split(";");
@@ -15,6 +15,7 @@ import type { QuestionController } from "../question/index.js";
15
15
  import type { MemoryScope } from "../memory/index.js";
16
16
  import type { ExternalHookController } from "../hooks/controller.js";
17
17
  import type { GoalStore } from "../goal/store.js";
18
+ import { isInternalBlockOnlyContent } from "../agent/internal-reminder-sanitizer.js";
18
19
  import type { ProcessManager } from "../tasks/manager.js";
19
20
  import type { PromotionChannel } from "../tasks/promotion.js";
20
21
  import type { ExternalRuntimeManager } from "../external-runtime/types.js";
@@ -70,11 +71,7 @@ export interface ExitSummary {
70
71
  /** Wall-clock duration of the session, in milliseconds. */
71
72
  wallMs: number;
72
73
  }
73
- /**
74
- * True when a user message is nothing but harness-injected internal blocks
75
- * (goal kicks, task wakes) — hidden live, so hidden on reconstruction too.
76
- */
77
- export declare function isInternalBlockOnlyContent(content: Message["content"]): boolean;
74
+ export { isInternalBlockOnlyContent };
78
75
  export declare function reconstructDisplayMessages(agentMessages: Message[]): DisplayMessage[];
79
76
  export declare const INK_LOCAL_SLASH_COMMANDS: readonly [{
80
77
  readonly name: "goal";
@@ -84,4 +81,3 @@ export declare const INK_LOCAL_SLASH_COMMANDS: readonly [{
84
81
  readonly description: "Run a prompt on a recurring interval (/loop 5m <prompt>|list|stop [id])";
85
82
  }];
86
83
  export declare function App({ agent, args, sessionManager: initialSessionManager, switchSession, createProvider, registry, skillRegistry, planHandlerRef, approvalHandlerRef, questionController, bashAllowlist, settingsManager, lspService, mcpManager, themeMode: initialThemeMode, themeOverrides, detectedTheme, onThemeModeChange, flushMemory, runMemoryCompaction, runMemorySummary, runMemoryRefresh, goalStore, processManager, tasksAutoResume, promotionChannel, bypassEnabled, updateNotice, updateNoticeRefresh, hookController, externalRuntime, onExit }: AppProps): import("react/jsx-runtime").JSX.Element;
87
- export {};