@bubblebrain-ai/bubble 0.0.59 → 0.0.61

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 (51) hide show
  1. package/dist/agent/subagent/runtime.d.ts +28 -0
  2. package/dist/agent/subagent/runtime.js +4 -24
  3. package/dist/agent.d.ts +7 -6
  4. package/dist/agent.js +18 -1
  5. package/dist/cli.d.ts +5 -0
  6. package/dist/cli.js +25 -2
  7. package/dist/config.d.ts +3 -0
  8. package/dist/config.js +7 -0
  9. package/dist/context/compact.js +6 -2
  10. package/dist/context/tool-images.d.ts +3 -0
  11. package/dist/context/tool-images.js +35 -0
  12. package/dist/context/usage.js +22 -5
  13. package/dist/feishu/agent-host/run-driver.js +3 -0
  14. package/dist/lsp/service.js +3 -2
  15. package/dist/main.js +11 -1
  16. package/dist/model-catalog.js +6 -0
  17. package/dist/model-pricing.js +17 -0
  18. package/dist/permissions/shell-command.js +35 -3
  19. package/dist/prompt/compose.d.ts +3 -0
  20. package/dist/prompt/compose.js +17 -3
  21. package/dist/provider-ai-sdk.d.ts +2 -1
  22. package/dist/provider-ai-sdk.js +26 -9
  23. package/dist/provider-openai-codex.d.ts +4 -0
  24. package/dist/provider-openai-codex.js +28 -9
  25. package/dist/provider-registry.js +4 -2
  26. package/dist/sdk/index.d.ts +21 -2
  27. package/dist/sdk/index.js +92 -11
  28. package/dist/session.d.ts +5 -1
  29. package/dist/session.js +13 -5
  30. package/dist/skills/format.d.ts +31 -1
  31. package/dist/skills/format.js +45 -24
  32. package/dist/slash-commands/commands.d.ts +3 -0
  33. package/dist/slash-commands/commands.js +6 -4
  34. package/dist/system-prompt.d.ts +3 -1
  35. package/dist/tools/edit-apply.d.ts +0 -3
  36. package/dist/tools/edit-apply.js +45 -249
  37. package/dist/tools/edit.js +3 -3
  38. package/dist/tools/grep.js +20 -1
  39. package/dist/tools/skill.d.ts +2 -0
  40. package/dist/tools/skill.js +34 -2
  41. package/dist/tui/app.js +6 -0
  42. package/dist/tui/components/skills-panel.d.ts +4 -0
  43. package/dist/tui/components/skills-panel.js +15 -1
  44. package/dist/tui/components/tasks-pane.d.ts +38 -0
  45. package/dist/tui/components/tasks-pane.js +182 -32
  46. package/dist/tui/controller/controller.d.ts +7 -0
  47. package/dist/tui/controller/controller.js +13 -0
  48. package/dist/types.d.ts +7 -0
  49. package/package.json +1 -1
  50. package/dist/prompt/skills.d.ts +0 -2
  51. package/dist/prompt/skills.js +0 -4
@@ -1,3 +1,28 @@
1
+ /**
2
+ * Subagent thread lifecycle + dynamic-workflow execution.
3
+ *
4
+ * Extracted from Agent: spawn/wait/send/close, the scheduler dispatch and its
5
+ * terminal callbacks, admission, ingestion, worktree-backed child construction
6
+ * and the QuickJS workflow driver all lived on the Agent class alongside the
7
+ * conversation loop, sharing its private fields.
8
+ *
9
+ * Two rules keep the boundary honest:
10
+ * - Everything the runtime needs from the parent arrives through
11
+ * `SubagentRuntimeParent` as a LIVE accessor, never a construction-time
12
+ * snapshot: `/model` reassigns the parent's provider/model/thinking
13
+ * mid-session and children must inherit the current values.
14
+ * - Conversation state is never pushed back into the parent. Tool updates
15
+ * and ingestion notices are PULLED (`drainToolUpdates` /
16
+ * `drainIngestionNotices`). The only direct sink is a terminal,
17
+ * fail-closed provider diagnostic that contains no child transcript.
18
+ *
19
+ * `new Agent(...)` stays on Agent, reached through `parent.createChild`. That
20
+ * is the only genuine call back into the parent, and keeping it there is what
21
+ * guarantees the child-construction argument set — including the fields that
22
+ * must stay ABSENT (onMessageAppend, sessionID, routing catalog) — has exactly
23
+ * one definition site.
24
+ */
25
+ import type { SkillSummary } from "../../skills/types.js";
1
26
  import { type AgentProfile, type SubagentRunResult } from "../profiles.js";
2
27
  import { type SubagentThreadRecord, type SubagentThreadSnapshot } from "../subagent-control.js";
3
28
  import { SubagentStore } from "../subagent-store.js";
@@ -36,6 +61,9 @@ export interface SubagentRuntimeParent {
36
61
  readonly apiModel: string;
37
62
  readonly thinkingLevel: ThinkingLevel;
38
63
  readonly memoryPrompt: string | undefined;
64
+ /** Optional so lightweight parents (tests, embedders) need not provide a catalog. */
65
+ getSkillSummaries?(): SkillSummary[];
66
+ getSkillCatalogChars?(): number | undefined;
39
67
  readonly providerFactory: ((route: ResolvedSubagentRoute) => Provider | Promise<Provider>) | undefined;
40
68
  /**
41
69
  * The UNFILTERED tool map — deferred-but-unlocked entries included. Profile
@@ -1,27 +1,3 @@
1
- /**
2
- * Subagent thread lifecycle + dynamic-workflow execution.
3
- *
4
- * Extracted from Agent: spawn/wait/send/close, the scheduler dispatch and its
5
- * terminal callbacks, admission, ingestion, worktree-backed child construction
6
- * and the QuickJS workflow driver all lived on the Agent class alongside the
7
- * conversation loop, sharing its private fields.
8
- *
9
- * Two rules keep the boundary honest:
10
- * - Everything the runtime needs from the parent arrives through
11
- * `SubagentRuntimeParent` as a LIVE accessor, never a construction-time
12
- * snapshot: `/model` reassigns the parent's provider/model/thinking
13
- * mid-session and children must inherit the current values.
14
- * - Conversation state is never pushed back into the parent. Tool updates
15
- * and ingestion notices are PULLED (`drainToolUpdates` /
16
- * `drainIngestionNotices`). The only direct sink is a terminal,
17
- * fail-closed provider diagnostic that contains no child transcript.
18
- *
19
- * `new Agent(...)` stays on Agent, reached through `parent.createChild`. That
20
- * is the only genuine call back into the parent, and keeping it there is what
21
- * guarantees the child-construction argument set — including the fields that
22
- * must stay ABSENT (onMessageAppend, sessionID, routing catalog) — has exactly
23
- * one definition site.
24
- */
25
1
  import { AgentRunInputQueue } from "../input-controller.js";
26
2
  import { randomUUID } from "node:crypto";
27
3
  import { buildSystemPrompt } from "../../system-prompt.js";
@@ -714,6 +690,10 @@ export class SubagentRuntime {
714
690
  mode: childMode,
715
691
  workingDir: childCwd,
716
692
  ...buildToolPromptOptions(tools),
693
+ // A child that can load skills sees the same catalog as its parent.
694
+ ...(childToolNames.includes("skill")
695
+ ? { skills: this.parent.getSkillSummaries?.(), skillCatalogChars: this.parent.getSkillCatalogChars?.() }
696
+ : {}),
717
697
  memoryPrompt: childToolNames.some((name) => name === "memory")
718
698
  ? this.parent.memoryPrompt
719
699
  : undefined,
package/dist/agent.d.ts CHANGED
@@ -1,7 +1,3 @@
1
- /**
2
- * Agent - The core decision loop.
3
- * It maintains message state, calls the LLM, executes tools, and auto-continues.
4
- */
5
1
  import { type ContextCheckpoint } from "./context/checkpoint.js";
6
2
  import { type ContextUsageSnapshot } from "./context/usage.js";
7
3
  import type { AgentEvent, AgentInputController, ContentPart, PermissionMode, Message, Provider, ThinkingLevel, ToolResult, ToolRegistryEntry, ToolUpdate } from "./types.js";
@@ -79,6 +75,8 @@ export interface AgentOptions {
79
75
  subAgentId?: string;
80
76
  };
81
77
  skills?: SkillSummary[];
78
+ /** Skill catalog budget in characters for rebuilt system prompts (0 = off). */
79
+ skillCatalogChars?: number;
82
80
  memoryPrompt?: string;
83
81
  fileStateTracker?: FileStateTracker;
84
82
  agentCategories?: AgentCategoriesConfig;
@@ -180,6 +178,7 @@ export declare class Agent {
180
178
  private budgetLedger?;
181
179
  private budgetSource;
182
180
  private skillSummaries;
181
+ private skillCatalogChars?;
183
182
  private memoryPrompt?;
184
183
  private fileStateTracker?;
185
184
  private agentCategories;
@@ -242,8 +241,10 @@ export declare class Agent {
242
241
  unlockDeferredTools(names: string[]): void;
243
242
  /** All deferred tools in this session (for tool_search to inspect). */
244
243
  listDeferredTools(): ToolRegistryEntry[];
245
- getSystemPromptToolOptions(): Pick<import("./system-prompt.js").SystemPromptOptions, "tools" | "toolSnippets" | "guidelines" | "modelRoutingPrompt" | "memoryPrompt">;
246
- /** Refresh the live Skill catalog after /skills reloads or changes enablement. */
244
+ getSystemPromptToolOptions(): Pick<import("./system-prompt.js").SystemPromptOptions, "tools" | "toolSnippets" | "guidelines" | "modelRoutingPrompt" | "memoryPrompt" | "skills" | "skillCatalogChars">;
245
+ getSkillSummaries(): SkillSummary[];
246
+ getSkillCatalogChars(): number | undefined;
247
+ /** Refresh the live Skill catalog after /skills reloads or changes enablement; hosts then rebuild the system prompt. */
247
248
  setSkillSummaries(skills: SkillSummary[]): void;
248
249
  /** Current routing menu (design §4); undefined when no accessor is wired. */
249
250
  buildModelRoutingPromptSection(): string | undefined;
package/dist/agent.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { toolImageObservation } from "./context/tool-images.js";
1
2
  /**
2
3
  * Agent - The core decision loop.
3
4
  * It maintains message state, calls the LLM, executes tools, and auto-continues.
@@ -142,6 +143,7 @@ export class Agent {
142
143
  budgetLedger;
143
144
  budgetSource;
144
145
  skillSummaries;
146
+ skillCatalogChars;
145
147
  memoryPrompt;
146
148
  fileStateTracker;
147
149
  agentCategories;
@@ -195,6 +197,7 @@ export class Agent {
195
197
  this.budgetLedger = options.budgetLedger;
196
198
  this.budgetSource = options.budgetSource ?? { runId: this.sessionID ?? "agent" };
197
199
  this.skillSummaries = options.skills ?? [];
200
+ this.skillCatalogChars = options.skillCatalogChars;
198
201
  this.memoryPrompt = options.memoryPrompt;
199
202
  this.fileStateTracker = options.fileStateTracker;
200
203
  this.agentCategories = options.agentCategories ?? {};
@@ -236,6 +239,8 @@ export class Agent {
236
239
  // must see deferred-but-registered tools (an explicit include in a
237
240
  // profile is the author pre-unlocking them).
238
241
  allTools: () => [...this.tools.values()],
242
+ getSkillSummaries: () => this.getSkillSummaries(),
243
+ getSkillCatalogChars: () => this.getSkillCatalogChars(),
239
244
  createChild: (spec) => this.createChildAgent(spec),
240
245
  recordProviderError: (error, context) => this.recordProviderError(error, context),
241
246
  runExternalHook: (input, abortSignal) => this.runExternalHook(input, abortSignal),
@@ -371,6 +376,9 @@ export class Agent {
371
376
  }
372
377
  getSystemPromptToolOptions() {
373
378
  return {
379
+ // Rebuilt prompts (model switch, /skills changes) keep the Skills catalog.
380
+ skills: this.skillSummaries,
381
+ skillCatalogChars: this.skillCatalogChars,
374
382
  ...buildToolPromptOptions(this.getActiveToolEntries()),
375
383
  // Rendered through the live accessor (design §1.5), so every host-
376
384
  // triggered prompt rebuild picks up the current catalog.
@@ -380,7 +388,13 @@ export class Agent {
380
388
  memoryPrompt: this.memoryPrompt,
381
389
  };
382
390
  }
383
- /** Refresh the live Skill catalog after /skills reloads or changes enablement. */
391
+ getSkillSummaries() {
392
+ return this.skillSummaries.slice();
393
+ }
394
+ getSkillCatalogChars() {
395
+ return this.skillCatalogChars;
396
+ }
397
+ /** Refresh the live Skill catalog after /skills reloads or changes enablement; hosts then rebuild the system prompt. */
384
398
  setSkillSummaries(skills) {
385
399
  this.skillSummaries = skills.slice();
386
400
  this.notifyContextChanged();
@@ -1489,6 +1503,9 @@ export class Agent {
1489
1503
  throwIfAborted(abortSignal);
1490
1504
  }
1491
1505
  }
1506
+ const imageObservation = toolImageObservation(executedResults);
1507
+ if (imageObservation)
1508
+ this.appendMessage(imageObservation);
1492
1509
  // A checkpoint must contain the whole batch, never provider repair's
1493
1510
  // placeholder for a sibling that has not executed yet.
1494
1511
  this.compactResidentHistory();
package/dist/cli.d.ts CHANGED
@@ -30,3 +30,8 @@ export interface CliArgs {
30
30
  }
31
31
  export declare function parseArgs(argv: string[]): CliArgs;
32
32
  export declare function printHelp(): void;
33
+ /**
34
+ * Make `cwd` the process working directory, so --cwd behaves exactly like
35
+ * launching Bubble there. Returns an error message instead of throwing.
36
+ */
37
+ export declare function enterWorkingDirectory(cwd: string): string | undefined;
package/dist/cli.js CHANGED
@@ -1,6 +1,8 @@
1
1
  /**
2
2
  * CLI argument parsing.
3
3
  */
4
+ import { statSync } from "node:fs";
5
+ import { resolve } from "node:path";
4
6
  import { THINKING_LEVELS } from "./types.js";
5
7
  import { isThinkingLevel } from "./variant/thinking-level.js";
6
8
  export function parseArgs(argv) {
@@ -27,9 +29,13 @@ export function parseArgs(argv) {
27
29
  case "-m":
28
30
  args.model = argv[++i];
29
31
  break;
30
- case "--cwd":
31
- args.cwd = argv[++i];
32
+ case "--cwd": {
33
+ // Absolute, so it survives the chdir in main() and matches session metadata.
34
+ const dir = argv[++i];
35
+ if (dir)
36
+ args.cwd = resolve(dir);
32
37
  break;
38
+ }
33
39
  case "--api-key":
34
40
  case "-k":
35
41
  args.apiKey = argv[++i];
@@ -150,3 +156,20 @@ Options (serve --feishu):
150
156
  --dry-run Connect once, then exit (smoke test)
151
157
  `);
152
158
  }
159
+ /**
160
+ * Make `cwd` the process working directory, so --cwd behaves exactly like
161
+ * launching Bubble there. Returns an error message instead of throwing.
162
+ */
163
+ export function enterWorkingDirectory(cwd) {
164
+ if (cwd === process.cwd())
165
+ return undefined;
166
+ try {
167
+ if (!statSync(cwd).isDirectory())
168
+ return `--cwd ${cwd} is not a directory.`;
169
+ process.chdir(cwd);
170
+ return undefined;
171
+ }
172
+ catch {
173
+ return `--cwd ${cwd} does not exist or cannot be opened.`;
174
+ }
175
+ }
package/dist/config.d.ts CHANGED
@@ -20,6 +20,7 @@ export interface UserConfigData {
20
20
  /** Skill names disabled from invocation/search until re-enabled in /skills. */
21
21
  skills?: {
22
22
  disabled?: string[];
23
+ catalogChars?: number;
23
24
  };
24
25
  /**
25
26
  * Three shapes are accepted on disk so we can evolve without breaking
@@ -76,6 +77,8 @@ export declare class UserConfig {
76
77
  setDefaultProvider(id: string): void;
77
78
  getSkillPaths(): string[];
78
79
  setSkillPaths(paths: string[]): void;
80
+ /** Skill catalog budget in characters; undefined uses the default, 0 turns the catalog off. */
81
+ getSkillCatalogChars(): number | undefined;
79
82
  getDisabledSkills(): string[];
80
83
  setDisabledSkills(names: string[]): void;
81
84
  getTheme(): ThemeConfig;
package/dist/config.js CHANGED
@@ -202,6 +202,13 @@ export class UserConfig {
202
202
  this.data.skillPaths = paths.slice();
203
203
  this.save();
204
204
  }
205
+ /** Skill catalog budget in characters; undefined uses the default, 0 turns the catalog off. */
206
+ getSkillCatalogChars() {
207
+ const value = this.data.skills?.catalogChars;
208
+ return typeof value === "number" && Number.isFinite(value) && value >= 0
209
+ ? Math.min(Math.floor(value), 200_000)
210
+ : undefined;
211
+ }
205
212
  getDisabledSkills() {
206
213
  const disabled = this.data.skills?.disabled;
207
214
  if (!Array.isArray(disabled))
@@ -59,7 +59,9 @@ export function isCompactionSummaryMessage(message) {
59
59
  }
60
60
  /** A message the user actually typed (not a projected reminder, not a summary). */
61
61
  export function isRealUserMessage(message) {
62
- if (message.role !== "user")
62
+ // Tool screenshots ride in a user-role message for the provider, but they
63
+ // belong to the tool turn: never a turn boundary.
64
+ if (message.role !== "user" || message.toolObservation)
63
65
  return false;
64
66
  const text = messageText(message);
65
67
  if (!text.trim() || isInternalBlockContent(text))
@@ -302,7 +304,9 @@ export function compactCurrentTurnToolGroups(messages, options = {}) {
302
304
  groups.push(current);
303
305
  current = { assistant: msg, toolResults: [] };
304
306
  }
305
- else if (msg.role === "tool" && current) {
307
+ else if ((msg.role === "tool" || (msg.role === "user" && msg.toolObservation)) && current) {
308
+ // A screenshot observation follows its tool result: it lives and dies
309
+ // with that group instead of vanishing from kept groups.
306
310
  current.toolResults.push(msg);
307
311
  }
308
312
  }
@@ -0,0 +1,3 @@
1
+ import type { ToolResult, UserMessage } from "../types.js";
2
+ /** All tool responses must precede the observation (valid for every provider). */
3
+ export declare function toolImageObservation(results: ToolResult[]): UserMessage | null;
@@ -0,0 +1,35 @@
1
+ /** All tool responses must precede the observation (valid for every provider). */
2
+ export function toolImageObservation(results) {
3
+ const content = [];
4
+ let bytes = 0;
5
+ for (const result of results) {
6
+ if (result.isError)
7
+ continue;
8
+ for (const image of result.images ?? []) {
9
+ if (content.length >= 8 ||
10
+ !["image/png", "image/jpeg"].includes(image.mimeType))
11
+ continue;
12
+ if (typeof image.data !== "string" ||
13
+ image.data.length > 4_000_000 ||
14
+ !/^[a-zA-Z0-9+/]+={0,2}$/.test(image.data))
15
+ continue;
16
+ const raw = Buffer.from(image.data, "base64");
17
+ if (bytes + raw.length > 6_000_000)
18
+ continue;
19
+ if (image.mimeType === "image/png"
20
+ ? raw.subarray(0, 8).toString("hex") !== "89504e470d0a1a0a"
21
+ : raw.subarray(0, 3).toString("hex") !== "ffd8ff")
22
+ continue;
23
+ bytes += raw.length;
24
+ content.push({
25
+ type: "text",
26
+ text: `Screenshot observation from the preceding tool result (not a new user request):\n${result.content.slice(0, 2000)}`,
27
+ });
28
+ content.push({
29
+ type: "image_url",
30
+ image_url: { url: `data:${image.mimeType};base64,${image.data}` },
31
+ });
32
+ }
33
+ }
34
+ return content.length ? { role: "user", content, toolObservation: true } : null;
35
+ }
@@ -1,5 +1,5 @@
1
1
  import { getModelContextWindow } from "../model-catalog.js";
2
- import { formatSkillsPrompt } from "../skills/format.js";
2
+ import { SKILL_CATALOG_HEADING, SKILL_CATALOG_INTRO } from "../skills/format.js";
3
3
  import { buildDeferredToolsReminder } from "../prompt/reminders.js";
4
4
  import { AUTOCOMPACT_BUFFER_TOKENS, estimateMessageTokens, estimateTextTokens, MIN_WINDOW_FOR_RESERVE, OUTPUT_RESERVE_TOKENS, } from "./budget.js";
5
5
  export function buildContextUsageSnapshot(input) {
@@ -7,8 +7,12 @@ export function buildContextUsageSnapshot(input) {
7
7
  const otherMessages = input.messages.filter((message) => message.role !== "system");
8
8
  const deferredToolEntries = input.deferredToolEntries ?? [];
9
9
  const systemContent = systemMessages.map((message) => message.content).join("\n\n");
10
- const skillsPrompt = formatSkillsPrompt(input.skills);
11
- const skillsInSystemPrompt = !!skillsPrompt && systemContent.includes(skillsPrompt);
10
+ // The catalog is whatever Skills section the prompt actually carries, whatever its budget.
11
+ const skillsPrompt = extractSkillCatalog(systemContent);
12
+ const skillsInSystemPrompt = !!skillsPrompt;
13
+ const advertisedSkills = skillsPrompt
14
+ ? skillsPrompt.split("\n").filter((line) => line.startsWith("- ") && !line.startsWith("- … ")).length
15
+ : 0;
12
16
  const skillsTokens = skillsInSystemPrompt ? estimateTextTokens(skillsPrompt, input.providerId) : 0;
13
17
  const systemPromptTokens = Math.max(0, estimateTextTokens(systemContent, input.providerId) - skillsTokens);
14
18
  const toolsTokens = estimateToolEntriesTokens(input.toolEntries, input.providerId);
@@ -37,7 +41,7 @@ export function buildContextUsageSnapshot(input) {
37
41
  skills: {
38
42
  label: "Skills",
39
43
  tokens: skillsTokens,
40
- detail: skillsInSystemPrompt && input.skills.length > 0 ? `${input.skills.length} advertised skill${input.skills.length === 1 ? "" : "s"}` : "none in current prompt",
44
+ detail: advertisedSkills > 0 ? `${advertisedSkills} advertised skill${advertisedSkills === 1 ? "" : "s"}` : "none in current prompt",
41
45
  },
42
46
  deferredTools: {
43
47
  label: "Deferred/MCP",
@@ -54,7 +58,7 @@ export function buildContextUsageSnapshot(input) {
54
58
  },
55
59
  toolCount: input.toolEntries.length,
56
60
  deferredToolCount: deferredToolEntries.length,
57
- skillCount: skillsInSystemPrompt ? input.skills.length : 0,
61
+ skillCount: advertisedSkills,
58
62
  messageCount: input.messages.length,
59
63
  };
60
64
  }
@@ -211,3 +215,16 @@ function colorForLabel(label) {
211
215
  return ANSI_DARK_GRAY;
212
216
  return ANSI_GRAY;
213
217
  }
218
+ /**
219
+ * The Skills catalog the prompt carries: heading plus its fixed intro line, so a
220
+ * "## Skills" heading in AGENTS.md or memory is never mistaken for it. The last
221
+ * match wins (the catalog is appended after memory); it ends at the next blank line.
222
+ */
223
+ function extractSkillCatalog(systemContent) {
224
+ const marker = `${SKILL_CATALOG_HEADING}\n${SKILL_CATALOG_INTRO}\n`;
225
+ const at = systemContent.lastIndexOf(marker);
226
+ if (at < 0 || (at > 0 && !systemContent.slice(0, at).endsWith("\n\n")))
227
+ return "";
228
+ const end = systemContent.indexOf("\n\n", at);
229
+ return systemContent.slice(at, end < 0 ? undefined : end);
230
+ }
@@ -99,6 +99,8 @@ export class RunDriver {
99
99
  const routingSnapshotAccessor = createRoutingSnapshotAccessor(this.opts.deps.providerRegistry, () => this.opts.deps.userConfig.getAgentCategories(), () => agentRouting);
100
100
  const systemPrompt = buildSystemPrompt({
101
101
  agentName: "Bubble",
102
+ skills,
103
+ skillCatalogChars: this.opts.deps.userConfig.getSkillCatalogChars(),
102
104
  configuredProvider: providerId || "none",
103
105
  configuredModel: model ? displayModel(model) : "none",
104
106
  configuredModelId: model || "none",
@@ -188,6 +190,7 @@ export class RunDriver {
188
190
  },
189
191
  budgetLedger,
190
192
  skills,
193
+ skillCatalogChars: this.opts.deps.userConfig.getSkillCatalogChars(),
191
194
  memoryPrompt,
192
195
  fileStateTracker,
193
196
  agentCategories: this.opts.deps.userConfig.getAgentCategories(),
@@ -2,7 +2,7 @@ import { execFile as execFileCallback, spawn } from "node:child_process";
2
2
  import { EventEmitter } from "node:events";
3
3
  import { access, readFile } from "node:fs/promises";
4
4
  import { createRequire } from "node:module";
5
- import { basename, dirname, extname, isAbsolute, join, normalize, relative, resolve } from "node:path";
5
+ import { basename, dirname, extname, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
6
6
  import { promisify } from "node:util";
7
7
  import { pathToFileURL, fileURLToPath } from "node:url";
8
8
  import { createMessageConnection, StreamMessageReader, StreamMessageWriter, } from "vscode-jsonrpc/node.js";
@@ -500,7 +500,8 @@ export class ProjectLspService {
500
500
  resolveInsideCwd(filePath) {
501
501
  const file = normalize(isAbsolute(filePath) ? filePath : resolve(this.cwd, filePath));
502
502
  const rel = relative(this.cwd, file);
503
- if (rel.startsWith("..") || isAbsolute(rel))
503
+ // Only a whole `..` segment climbs out; `..notes.md` is a file in the project.
504
+ if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel))
504
505
  return undefined;
505
506
  return file;
506
507
  }
package/dist/main.js CHANGED
@@ -5,7 +5,7 @@
5
5
  import chalk from "chalk";
6
6
  import { Agent } from "./agent.js";
7
7
  import { BudgetLedger } from "./agent/budget-ledger.js";
8
- import { parseArgs, printHelp } from "./cli.js";
8
+ import { enterWorkingDirectory, parseArgs, printHelp } from "./cli.js";
9
9
  import { effectiveThemeModeForTerminal, shouldProbeTerminalTheme, UserConfig } from "./config.js";
10
10
  import { createProviderInstance, createUnavailableProvider } from "./provider.js";
11
11
  import { resolveConfiguredModel } from "./model-selection.js";
@@ -56,6 +56,13 @@ async function main() {
56
56
  console.log(`v${getCurrentVersion()}`);
57
57
  process.exit(0);
58
58
  }
59
+ // --cwd means "run as if launched there" (like git -C): every layer, including
60
+ // the TUI, shell children and relative paths, then sees the same directory.
61
+ const cwdError = enterWorkingDirectory(args.cwd);
62
+ if (cwdError) {
63
+ console.error(`bubble: ${cwdError}`);
64
+ process.exit(1);
65
+ }
59
66
  if (args.command === "update") {
60
67
  const { runUpdateCommand } = await import("./update/index.js");
61
68
  const code = await runUpdateCommand({ checkOnly: args.checkOnly });
@@ -379,6 +386,8 @@ async function main() {
379
386
  : undefined;
380
387
  const systemPrompt = buildSystemPrompt({
381
388
  agentName: "Bubble",
389
+ skills: skillSummaries,
390
+ skillCatalogChars: userConfig.getSkillCatalogChars(),
382
391
  configuredProvider: activeProviderId || "none",
383
392
  configuredModel: activeModel ? displayModel(activeModel) : "none",
384
393
  configuredModelId: activeModel || "none",
@@ -493,6 +502,7 @@ async function main() {
493
502
  },
494
503
  budgetLedger,
495
504
  skills: skillSummaries,
505
+ skillCatalogChars: userConfig.getSkillCatalogChars(),
496
506
  memoryPrompt,
497
507
  fileStateTracker,
498
508
  agentCategories: userConfig.getAgentCategories(),
@@ -88,6 +88,9 @@ const ANTHROPIC_CHAT_LEVELS = ["off"];
88
88
  // Grok 4.5 exposes low/medium/high effort with no "off" (thinking always on);
89
89
  // the composer model declares no effort support at all.
90
90
  const GROK_45_LEVELS = ["low", "medium", "high"];
91
+ // Gemini 4 documents only low/medium (default)/high. Verified live on
92
+ // 2026-10-13: thinkingLevel=minimal returns 400, so there is no off/minimal.
93
+ const GEMINI_4_LEVELS = ["low", "medium", "high"];
91
94
  const GEMINI_3_LEVELS = ["low", "medium", "high"];
92
95
  const GEMINI_3_FLASH_LEVELS = ["minimal", "low", "medium", "high"];
93
96
  // Verified against the native API on 2026-09-02: Gemini 3.7/3.8 Flash reject
@@ -149,6 +152,9 @@ export const BUILTIN_MODELS = [
149
152
  // list via fetchGeminiModels (GET /v1beta/models, newest five). Gemini 3
150
153
  // thinking support varies by release: 3.7/3.8 Flash support off/low/medium/high
151
154
  // while older Flash releases expose minimal/low/medium/high.
155
+ // Gemini 4 Argon: 1,048,576-token window shared by input, thinking and output.
156
+ // https://ai.google.dev/gemini-api/docs/models/gemini-4-argon
157
+ { id: "gemini-4-argon", name: "Gemini 4 Argon", providerId: "google", tier: "strong", reasoningLevels: GEMINI_4_LEVELS, defaultReasoningLevel: "medium", contextWindow: 1048576 },
152
158
  { id: "gemini-3.8-flash", name: "Gemini 3.8 Flash", providerId: "google", tier: "fast", reasoningLevels: GEMINI_37_38_FLASH_LEVELS, contextWindow: 1048576 },
153
159
  { id: "gemini-3.7-flash", name: "Gemini 3.7 Flash", providerId: "google", tier: "fast", reasoningLevels: GEMINI_37_38_FLASH_LEVELS, contextWindow: 1048576 },
154
160
  { id: "gemini-3.5-flash", name: "Gemini 3.5 Flash", providerId: "google", tier: "fast", reasoningLevels: GEMINI_3_FLASH_LEVELS, contextWindow: 1048576 },
@@ -64,6 +64,23 @@ export const MODEL_PRICING_OVERRIDES = [
64
64
  outputPerMillion: 7.5,
65
65
  },
66
66
  })),
67
+ // Gemini 4 Argon introductory pricing through 2026-11-30; standard rates from
68
+ // 2026-12-01. Implicit caching only (95% off input), so no cache-write rate.
69
+ // https://ai.google.dev/gemini-api/docs/pricing
70
+ {
71
+ providerId: "google",
72
+ modelId: "gemini-4-argon",
73
+ currency: "USD",
74
+ inputCacheHitPerMillion: 0.1,
75
+ inputCacheMissPerMillion: 2,
76
+ outputPerMillion: 10,
77
+ effectiveUntil: "2026-12-01T00:00:00Z",
78
+ original: {
79
+ inputCacheHitPerMillion: 0.2,
80
+ inputCacheMissPerMillion: 4,
81
+ outputPerMillion: 20,
82
+ },
83
+ },
67
84
  {
68
85
  providerId: "deepseek",
69
86
  modelId: "deepseek-flash",
@@ -160,6 +160,33 @@ export function shellTokens(segment) {
160
160
  }
161
161
  /** Wrappers that run their argument as the real command. */
162
162
  const COMMAND_WRAPPERS = new Set(["command", "exec", "nohup", "builtin", "env", "sudo", "doas", "nice", "xargs"]);
163
+ /**
164
+ * Wrapper options that take a separate argument (`sudo -u root`), so that
165
+ * argument is not mistaken for the command. `env -S` is deliberately absent:
166
+ * its argument is the command line itself.
167
+ */
168
+ const WRAPPER_OPTION_ARGS = {
169
+ sudo: { short: "CDghpRrTtUu", long: ["chdir", "chroot", "close-from", "command-timeout", "group", "host", "other-user", "prompt", "role", "type", "user"] },
170
+ doas: { short: "Cu", long: [] },
171
+ env: { short: "aCPu", long: ["argv0", "chdir", "unset"] },
172
+ nice: { short: "n", long: ["adjustment"] },
173
+ // GNU --eof/--replace/--max-lines take a value only as `--opt=value`.
174
+ xargs: { short: "adEIJLnPRSs", long: ["arg-file", "delimiter", "max-args", "max-chars", "max-procs", "process-slot-var"] },
175
+ exec: { short: "a", long: [] },
176
+ };
177
+ /** getopt rules: in `-Eu root` the last letter takes the next word; in `-uroot` it is attached. */
178
+ function optionTakesNextWord(wrapper, option) {
179
+ const spec = WRAPPER_OPTION_ARGS[wrapper];
180
+ if (!spec)
181
+ return false;
182
+ if (option.startsWith("--"))
183
+ return !option.includes("=") && spec.long.includes(option.slice(2));
184
+ for (let index = 1; index < option.length; index++) {
185
+ if (spec.short.includes(option[index]))
186
+ return index === option.length - 1;
187
+ }
188
+ return false;
189
+ }
163
190
  /**
164
191
  * The words of a segment with leading `VAR=value` assignments and command
165
192
  * wrappers (`sudo -u x`, `env`, `nohup`, ...) removed. Deny rules match
@@ -177,9 +204,14 @@ export function effectiveCommandTokens(segment) {
177
204
  changed = true;
178
205
  }
179
206
  if (tokens.length > 0 && COMMAND_WRAPPERS.has(tokens[0])) {
180
- tokens.shift();
181
- while (tokens.length > 0 && tokens[0].startsWith("-"))
182
- tokens.shift();
207
+ const wrapper = tokens.shift();
208
+ while (tokens.length > 0 && tokens[0].startsWith("-")) {
209
+ const option = tokens.shift();
210
+ if (option === "--")
211
+ break;
212
+ if (optionTakesNextWord(wrapper, option))
213
+ tokens.shift();
214
+ }
183
215
  changed = true;
184
216
  }
185
217
  }
@@ -6,7 +6,10 @@ export interface ComposeSystemPromptOptions extends EnvironmentPromptOptions {
6
6
  guidelines?: string[];
7
7
  thinkingLevel?: ThinkingLevel;
8
8
  mode?: PermissionMode;
9
+ /** Prompt-visible skills; when given (and `skill` is a tool), listed as a catalog. */
9
10
  skills?: SkillSummary[];
11
+ /** Catalog budget in characters (default SKILL_CATALOG_CHARS); 0 falls back to search only. */
12
+ skillCatalogChars?: number;
10
13
  memoryPrompt?: string;
11
14
  agentProfilePrompt?: string;
12
15
  /** Rendered subagent model-routing menu (design §4). Placed right after the
@@ -9,6 +9,7 @@ import { buildKimiProviderPrompt } from "./provider-prompts/kimi.js";
9
9
  import { buildDelegationPolicyPrompt } from "./delegation.js";
10
10
  import { buildEnvironmentPrompt, defaultToolNames } from "./environment.js";
11
11
  import { buildRuntimePrompt } from "./runtime.js";
12
+ import { buildSkillCatalog } from "../skills/format.js";
12
13
  export function composeSystemPrompt(options = {}) {
13
14
  const agentName = options.agentName ?? "Bubble";
14
15
  const providerPrompt = buildProviderPrompt(agentName, options.configuredProvider, options.configuredModelId, options.configuredModel);
@@ -21,10 +22,20 @@ export function composeSystemPrompt(options = {}) {
21
22
  tools: options.tools ?? defaultToolNames,
22
23
  toolSnippets: options.toolSnippets,
23
24
  });
25
+ const tools = options.tools ?? defaultToolNames;
26
+ // Level one of skill loading: names and one-line descriptions are always in
27
+ // context; bodies load on demand with `skill`. Last section, so the prefix
28
+ // above stays byte-identical while the skill set is unchanged.
29
+ const skillCatalog = options.skills && tools.includes("skill")
30
+ ? buildSkillCatalog(options.skills, {
31
+ budgetChars: options.skillCatalogChars,
32
+ searchable: tools.includes("skill_search"),
33
+ })
34
+ : undefined;
24
35
  const runtimePrompt = buildRuntimePrompt({
25
36
  thinkingLevel: options.thinkingLevel,
26
37
  mode: options.mode,
27
- guidelines: buildGuidelines(options.tools ?? defaultToolNames, options.guidelines ?? []),
38
+ guidelines: buildGuidelines(tools, options.guidelines ?? [], skillCatalog),
28
39
  });
29
40
  const delegationPrompt = buildDelegationPolicyPrompt(options.tools ?? defaultToolNames);
30
41
  // Same gate as the delegation policy: children (no spawn_agent) never see the menu.
@@ -37,6 +48,7 @@ export function composeSystemPrompt(options = {}) {
37
48
  modelRoutingPrompt,
38
49
  options.agentProfilePrompt,
39
50
  options.memoryPrompt,
51
+ skillCatalog?.text,
40
52
  ].filter(Boolean).join("\n\n");
41
53
  }
42
54
  function buildProviderPrompt(agentName, providerId, modelId, modelName) {
@@ -67,7 +79,7 @@ function buildProviderPrompt(agentName, providerId, modelId, modelName) {
67
79
  }
68
80
  return buildDefaultProviderPrompt(agentName);
69
81
  }
70
- function buildGuidelines(tools, extraGuidelines) {
82
+ function buildGuidelines(tools, extraGuidelines, skillCatalog) {
71
83
  const guidelines = [];
72
84
  const add = (item) => {
73
85
  if (!guidelines.includes(item)) {
@@ -86,7 +98,9 @@ function buildGuidelines(tools, extraGuidelines) {
86
98
  if (tools.includes("question")) {
87
99
  add("When the user is explicitly discussing, brainstorming, or shaping an approach instead of asking for immediate execution, use the question tool for targeted clarification or preference choices when it would materially improve the discussion; do not use it for generic permission-to-proceed questions");
88
100
  }
89
- if (tools.includes("skill_search") && tools.includes("skill")) {
101
+ // With a catalog the Skills section itself says how to load; search remains
102
+ // the only route when the host gave no catalog or turned it off.
103
+ if (!skillCatalog?.listed && tools.includes("skill_search") && tools.includes("skill")) {
90
104
  add("Skills may provide specialized workflows. When a task appears to match a specialized workflow, call skill_search to find relevant skills, then call skill with the exact name to load the selected skill before applying it");
91
105
  }
92
106
  for (const item of extraGuidelines) {
@@ -50,7 +50,8 @@ export declare function fetchGeminiModels(options: {
50
50
  }): Promise<GeminiModelDescriptor[]>;
51
51
  /**
52
52
  * Ranking: one entry per (version, tier) family — GA ids beat dated previews —
53
- * then newest version first, pro before flash before flash-lite, top N.
53
+ * then newest version first, flagship (pro/argon) before flash before
54
+ * flash-lite, top N.
54
55
  */
55
56
  export declare function selectLatestGeminiModels(entries: GeminiModelListEntry[], limit?: number): GeminiModelDescriptor[];
56
57
  /** Mirrors the static catalog's per-family thinking support. */