@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
@@ -137,17 +137,23 @@ function buildCallOptions(messages, chatOptions, providerOptions) {
137
137
  };
138
138
  }
139
139
  /**
140
- * Gemini 3 models take a graded thinking_level; 2.5-era models take a token
140
+ * Gemini 3+ models take a graded thinking_level; 2.5-era models take a token
141
141
  * budget. "off" (budget 0) is only offered in the catalog for models that
142
142
  * accept it (2.5 Flash); xhigh/max/ultra clamp to high.
143
143
  */
144
144
  function buildGoogleThinkingOptions(modelId, level) {
145
145
  if (!level)
146
146
  return undefined;
147
+ const version = geminiVersion(modelId);
148
+ // Gemini 4 accepts only low/medium/high: MINIMAL is a 400 and thinkingBudget
149
+ // is undocumented. Side calls (titles, compaction) request "off" by default,
150
+ // so floor those at low rather than sending a value the API rejects.
151
+ if (version >= 4 && (level === "off" || level === "minimal"))
152
+ level = "low";
147
153
  if (level === "off")
148
154
  return { thinkingConfig: { thinkingBudget: 0 } };
149
155
  const clamped = level === "xhigh" || level === "max" || level === "ultra" ? "high" : level;
150
- if (modelId.includes("gemini-3")) {
156
+ if (version >= 3) {
151
157
  return { thinkingConfig: { thinkingLevel: clamped, includeThoughts: true } };
152
158
  }
153
159
  const budgets = { minimal: 512, low: 2048, medium: 8192, high: 24576 };
@@ -459,10 +465,13 @@ export async function fetchGeminiModels(options) {
459
465
  }
460
466
  /**
461
467
  * Ranking: one entry per (version, tier) family — GA ids beat dated previews —
462
- * then newest version first, pro before flash before flash-lite, top N.
468
+ * then newest version first, flagship (pro/argon) before flash before
469
+ * flash-lite, top N.
463
470
  */
464
471
  export function selectLatestGeminiModels(entries, limit = 5) {
465
- const TIER_RANK = { pro: 0, flash: 1, "flash-lite": 2 };
472
+ // Gemini 4 introduced named flagship tiers (gemini-4-argon). Unknown names
473
+ // stay excluded rather than guessing whether they are text models.
474
+ const TIER_RANK = { argon: 0, pro: 0, flash: 1, "flash-lite": 2 };
466
475
  const candidates = [];
467
476
  for (const entry of entries) {
468
477
  const id = (entry.name ?? "").replace(/^models\//, "");
@@ -472,21 +481,23 @@ export function selectLatestGeminiModels(entries, limit = 5) {
472
481
  continue;
473
482
  if (entry.supportedGenerationMethods && !entry.supportedGenerationMethods.includes("generateContent"))
474
483
  continue;
475
- const match = /^gemini-(\d+(?:\.\d+)?)-(pro|flash)(-lite)?/.exec(id);
484
+ const match = /^gemini-(\d+(?:\.\d+)?)-(argon|pro|flash)(-lite)?/.exec(id);
476
485
  if (!match)
477
486
  continue;
487
+ const tier = `${match[2]}${match[3] ?? ""}`;
478
488
  candidates.push({
479
489
  id,
480
490
  entry,
481
491
  version: Number.parseFloat(match[1]),
482
- tierRank: TIER_RANK[`${match[2]}${match[3] ?? ""}`] ?? 3,
492
+ tier,
493
+ tierRank: TIER_RANK[tier] ?? 3,
483
494
  isPreview: id.includes("preview") || id.includes("exp"),
484
495
  });
485
496
  }
486
497
  // One winner per family: GA over preview, then the shortest (least-suffixed) id.
487
498
  const families = new Map();
488
499
  for (const candidate of candidates) {
489
- const key = `${candidate.version}-${candidate.tierRank}`;
500
+ const key = `${candidate.version}-${candidate.tier}`;
490
501
  const current = families.get(key);
491
502
  if (!current
492
503
  || (current.isPreview && !candidate.isPreview)
@@ -502,13 +513,19 @@ export function selectLatestGeminiModels(entries, limit = 5) {
502
513
  name: candidate.entry.displayName || candidate.id,
503
514
  contextWindow: Number.isFinite(candidate.entry.inputTokenLimit) ? candidate.entry.inputTokenLimit : undefined,
504
515
  reasoningLevels: geminiReasoningLevels(candidate.id),
505
- ...(candidate.tierRank === 0 ? { defaultReasoningLevel: "high" } : {}),
516
+ // Argon defaults to Google's recommended medium via the catalog.
517
+ ...(candidate.tier === "pro" ? { defaultReasoningLevel: "high" } : {}),
506
518
  }));
507
519
  }
520
+ function geminiVersion(modelId) {
521
+ return Number.parseFloat(/^gemini-(\d+(?:\.\d+)?)/.exec(modelId)?.[1] ?? "0");
522
+ }
508
523
  /** Mirrors the static catalog's per-family thinking support. */
509
524
  export function geminiReasoningLevels(modelId) {
510
- const version = Number.parseFloat(/^gemini-(\d+(?:\.\d+)?)/.exec(modelId)?.[1] ?? "0");
525
+ const version = geminiVersion(modelId);
511
526
  const isPro = modelId.includes("-pro");
527
+ if (version >= 4)
528
+ return ["low", "medium", "high"];
512
529
  // Live probes show 3.7 and 3.8 Flash share this newer ladder. Keep the
513
530
  // exception bounded to verified releases instead of guessing for 3.9+.
514
531
  if (!isPro && (version === 3.7 || version === 3.8)) {
@@ -47,6 +47,8 @@ export declare function fetchOpenAICodexModels(options: {
47
47
  export interface OpenAICodexModelCatalogResult {
48
48
  descriptors: CodexModelDescriptor[];
49
49
  status: "success" | "unavailable";
50
+ /** Why each discovery path failed, when status is "unavailable". */
51
+ reason?: string;
50
52
  }
51
53
  /**
52
54
  * Fetches the account-scoped Codex catalog while preserving the distinction
@@ -58,5 +60,7 @@ export declare function fetchOpenAICodexModelCatalog(options: {
58
60
  fetch?: ChatGptFetch;
59
61
  /** Bounds both catalog GETs; a stalled warm-up must not pin the in-flight slot. */
60
62
  signal?: AbortSignal;
63
+ /** Per-request timeout, so a slow first path still leaves the fallback a full window. */
64
+ timeoutMs?: number;
61
65
  }): Promise<OpenAICodexModelCatalogResult>;
62
66
  export declare function sortCodexModelDescriptors(descriptors: CodexModelDescriptor[]): CodexModelDescriptor[];
@@ -12,8 +12,9 @@ const TOKEN_REFRESH_GRACE_MS = 5 * 60 * 1000;
12
12
  // OpenAI gates new codex models server-side by client_version (each model carries a
13
13
  // `minimal_client_version`). Track a recent real Codex CLI release; override via env
14
14
  // when OpenAI lifts the gate again before we cut a new release.
15
- // 2026-09-17: gpt-6-astra requires 0.153.0; 0.155.0 is the current @openai/codex release.
16
- const CODEX_CLIENT_VERSION = process.env.BUBBLE_CODEX_CLIENT_VERSION?.trim() || "0.155.0";
15
+ // 2026-09-17: gpt-6-astra requires 0.153.0.
16
+ // 2026-10-06: gpt-6.1-sol requires 0.159.0, the version the Codex app sends.
17
+ const CODEX_CLIENT_VERSION = process.env.BUBBLE_CODEX_CLIENT_VERSION?.trim() || "0.159.0";
17
18
  /** The Codex client version Bubble claims; the account catalog the server returns depends on it. */
18
19
  export function getCodexClientVersion() {
19
20
  return CODEX_CLIENT_VERSION;
@@ -346,32 +347,50 @@ export async function fetchOpenAICodexModels(options) {
346
347
  export async function fetchOpenAICodexModelCatalog(options) {
347
348
  const accountId = extractChatGptAccountId(options.accessToken);
348
349
  if (!accountId) {
349
- return { descriptors: [], status: "unavailable" };
350
+ return { descriptors: [], status: "unavailable", reason: "access token has no ChatGPT account id" };
350
351
  }
351
352
  const fetchImpl = options.fetch ?? chatGptFetch;
353
+ const failures = [];
352
354
  for (const path of MODEL_DISCOVERY_PATHS) {
355
+ const signal = options.timeoutMs === undefined
356
+ ? options.signal
357
+ : options.signal
358
+ ? AbortSignal.any([options.signal, AbortSignal.timeout(options.timeoutMs)])
359
+ : AbortSignal.timeout(options.timeoutMs);
360
+ const startedAt = Date.now();
353
361
  const response = await fetchImpl(resolveRelativeUrl(options.baseURL, path), {
354
362
  method: "GET",
355
363
  headers: buildBaseHeaders(options.accessToken, accountId, globalThis.crypto?.randomUUID?.() ?? `bubble_${Date.now()}`, { accept: "application/json" }),
356
- signal: options.signal,
357
- }).catch(() => undefined);
358
- if (!response?.ok)
364
+ signal,
365
+ }).catch((error) => {
366
+ failures.push(`${path}: ${error instanceof Error ? error.message : String(error)} after ${Date.now() - startedAt}ms`);
367
+ return undefined;
368
+ });
369
+ if (!response)
370
+ continue;
371
+ if (!response.ok) {
372
+ failures.push(`${path}: HTTP ${response.status} after ${Date.now() - startedAt}ms`);
359
373
  continue;
374
+ }
360
375
  const parsed = await response.json()
361
376
  .then((payload) => ({ ok: true, payload }))
362
377
  .catch(() => ({ ok: false }));
363
- if (!parsed.ok)
378
+ if (!parsed.ok) {
379
+ failures.push(`${path}: invalid JSON`);
364
380
  continue;
381
+ }
365
382
  const payload = parsed.payload;
366
383
  if (!Array.isArray(payload) && !(payload && typeof payload === "object"
367
- && (Array.isArray(payload.models) || Array.isArray(payload.data))))
384
+ && (Array.isArray(payload.models) || Array.isArray(payload.data)))) {
385
+ failures.push(`${path}: unexpected payload`);
368
386
  continue;
387
+ }
369
388
  return {
370
389
  descriptors: sortCodexModelDescriptors(extractCodexModelDescriptors(payload)),
371
390
  status: "success",
372
391
  };
373
392
  }
374
- return { descriptors: [], status: "unavailable" };
393
+ return { descriptors: [], status: "unavailable", reason: failures.join("; ") };
375
394
  }
376
395
  function buildRequestBody(messages, options) {
377
396
  const instructions = messages
@@ -74,6 +74,8 @@ const MODEL_DISCOVERY_FAILURE_TTL_MS = 10_000;
74
74
  const MODEL_DISCOVERY_DISK_TTL_MS = 24 * 60 * 60 * 1000;
75
75
  /** Discovery must never delay startup or a model picker for long. */
76
76
  const MODEL_DISCOVERY_TIMEOUT_MS = 5_000;
77
+ // Per request: the ChatGPT backend is often reached through a user proxy.
78
+ const OPENAI_CODEX_MODEL_DISCOVERY_TIMEOUT_MS = 15_000;
77
79
  // Included in the cache identity so a pre-single-model OpenRouter disk cache
78
80
  // can never repopulate the picker with the old full remote catalog.
79
81
  const OPENROUTER_CATALOG_SCOPE = "ox-alpha-only-v2";
@@ -821,10 +823,10 @@ export class ProviderRegistry {
821
823
  const catalog = await fetchOpenAICodexModelCatalog({
822
824
  baseURL: currentProvider.baseURL,
823
825
  accessToken: currentProvider.apiKey,
824
- signal: AbortSignal.timeout(MODEL_DISCOVERY_TIMEOUT_MS),
826
+ timeoutMs: OPENAI_CODEX_MODEL_DISCOVERY_TIMEOUT_MS,
825
827
  });
826
828
  if (catalog.status === "unavailable") {
827
- throw new Error("OpenAI Codex model catalog is unavailable.");
829
+ throw new Error(`OpenAI Codex model catalog is unavailable${catalog.reason ? ` (${catalog.reason})` : ""}.`);
828
830
  }
829
831
  const visible = catalog.descriptors.filter((descriptor) => descriptor.visibility !== "hide");
830
832
  return {
@@ -22,7 +22,7 @@ import type { ResolvedSubagentRoute } from "../agent/categories.js";
22
22
  import { type QuestionAnswer, type QuestionRequest } from "../question/controller.js";
23
23
  import { SkillRegistry } from "../skills/registry.js";
24
24
  import type { SkillSummary } from "../skills/types.js";
25
- import type { AgentEvent, AgentRunInput, ContentPart, Message, PermissionMode, Provider, ThinkingLevel } from "../types.js";
25
+ import type { AgentEvent, AgentRunInput, ContentPart, Message, PermissionMode, Provider, ThinkingLevel, ToolRegistryEntry } from "../types.js";
26
26
  export interface BubbleSdkOptions {
27
27
  /** Optional process-local replay window. Active readers never lose unread
28
28
  * events. Older cursors fail explicitly; restore durable session history.
@@ -72,8 +72,22 @@ export interface TurnHandlers {
72
72
  onStart?: (info: TurnStartInfo) => void;
73
73
  }
74
74
  export interface RunTurnOptions extends TurnHandlers {
75
+ /**
76
+ * Turn-scoped host capabilities. Never cached globally or given to subagents;
77
+ * follow-up turns the SDK starts for this turn (queued input, pending steers)
78
+ * reuse this turn's options, these included.
79
+ */
80
+ hostTools?: ToolRegistryEntry[];
81
+ /**
82
+ * Extra skill directories for this turn, e.g. skills a host app ships with.
83
+ * Searched after the user's own roots, so a user skill of the same name wins.
84
+ * Like hostTools, follow-up turns the SDK starts for this turn reuse them.
85
+ */
86
+ skillPaths?: string[];
75
87
  /** Plain text, or content parts (text + base64 images) for attachments. */
76
88
  prompt: string | ContentPart[];
89
+ /** Preserve an admitted input if cancelled before initialization records it. */
90
+ preserveInterruptedInput?: boolean;
77
91
  /** "provider:model" or bare model id (resolved against the default provider). */
78
92
  model?: string;
79
93
  mode?: PermissionMode;
@@ -192,9 +206,14 @@ export declare class BubbleSdk {
192
206
  }): SdkSessionRef;
193
207
  openSession(sessionId: string): SdkSessionHandle;
194
208
  getHistory(sessionId: string): Message[];
209
+ /** Preserve an accepted desktop input cancelled before runTurn was admitted. */
210
+ recordInterruptedInput(sessionId: string, prompt: string | ContentPart[]): void;
211
+ private persistInterruptedInput;
195
212
  deleteSession(sessionId: string): Promise<void>;
196
213
  /** Interrupt the active turn. Queued turns survive unless cancelQueued is true. */
197
214
  stop(sessionId: string, options?: SdkStopOptions): number;
215
+ /** Stop and wait for persistence, cleanup, and terminal event publication. */
216
+ stopAndWait(sessionId: string, options?: SdkStopOptions): Promise<number>;
198
217
  /** Cancel queued turns without interrupting the turn that owns the session slot. */
199
218
  clearQueue(sessionId: string): number;
200
219
  getSessionRunState(sessionId: string): SdkSessionRunState;
@@ -205,7 +224,7 @@ export declare class BubbleSdk {
205
224
  steer(sessionId: string, content: string): SdkSteerResult;
206
225
  enqueueTurn(sessionId: string, options: RunTurnOptions): AsyncGenerator<AgentEvent>;
207
226
  queueTurn(sessionId: string, options: RunTurnOptions): AsyncGenerator<AgentEvent>;
208
- listSkills(cwd?: string): SkillSummary[];
227
+ listSkills(cwd?: string, skillPaths?: string[]): SkillSummary[];
209
228
  /**
210
229
  * Ask the host once per folder (onProjectTrust) before this turn loads the
211
230
  * repository's allow rules, MCP servers and LSP servers. Trusting restarts
package/dist/sdk/index.js CHANGED
@@ -118,6 +118,31 @@ export class BubbleSdk {
118
118
  return [];
119
119
  return this.resolveSession(sessionId)?.manager.getMessages() ?? [];
120
120
  }
121
+ /** Preserve an accepted desktop input cancelled before runTurn was admitted. */
122
+ recordInterruptedInput(sessionId, prompt) {
123
+ const owner = this.ownerForSession(sessionId);
124
+ if (owner && owner !== this)
125
+ return owner.recordInterruptedInput(sessionId, prompt);
126
+ if (this.turnCoordinator.isDeleted(sessionId) || this.getSessionRunState(sessionId).active) {
127
+ throw new Error(`Cannot record interrupted input while session is active or deleted: ${sessionId}`);
128
+ }
129
+ const resolved = this.resolveSession(sessionId);
130
+ if (!resolved)
131
+ throw new Error(`Unknown session: ${sessionId}`);
132
+ this.persistInterruptedInput(resolved, prompt);
133
+ }
134
+ persistInterruptedInput(resolved, prompt) {
135
+ const session = resolved.manager;
136
+ session.getMessages();
137
+ const revision = session.getRevision();
138
+ session.updateMetadata({ cwd: resolved.cwd });
139
+ session.appendMessage({ role: "user", content: prompt }, revision);
140
+ session.appendMessage({
141
+ role: "assistant",
142
+ content: "[Interrupted by user before execution started.]",
143
+ error: { name: "MessageAbortedError", message: "Turn stopped during initialization.", aborted: true },
144
+ }, session.getRevision());
145
+ }
121
146
  async deleteSession(sessionId) {
122
147
  const owner = this.ownerForSession(sessionId);
123
148
  if (owner && owner !== this)
@@ -147,8 +172,21 @@ export class BubbleSdk {
147
172
  const current = this.turnCoordinator.getCurrent(sessionId);
148
173
  const runtime = current ? this.turnRuntimes.get(current.id) : undefined;
149
174
  runtime?.inputController.closePendingInputs();
150
- const stopped = this.turnCoordinator.stopCurrent(sessionId);
151
- return stopped + (options.cancelQueued ? this.turnCoordinator.clearQueue(sessionId) : 0);
175
+ // A reserved turn can release its slot synchronously when cancelled.
176
+ // Clear the queue first so cancellation cannot promote an unwanted input.
177
+ const queued = options.cancelQueued ? this.turnCoordinator.clearQueue(sessionId) : 0;
178
+ return queued + this.turnCoordinator.stopCurrent(sessionId);
179
+ }
180
+ /** Stop and wait for persistence, cleanup, and terminal event publication. */
181
+ async stopAndWait(sessionId, options = {}) {
182
+ const owner = this.ownerForSession(sessionId);
183
+ if (owner && owner !== this)
184
+ return owner.stopAndWait(sessionId, options);
185
+ const current = this.turnCoordinator.getCurrent(sessionId);
186
+ const stopping = this.runtimesForSession(sessionId).filter(runtime => options.cancelQueued || runtime.reservation.id === current?.id);
187
+ const stopped = this.stop(sessionId, options);
188
+ await Promise.all(stopping.map(runtime => runtime.finished));
189
+ return stopped;
152
190
  }
153
191
  /** Cancel queued turns without interrupting the turn that owns the session slot. */
154
192
  clearQueue(sessionId) {
@@ -205,10 +243,10 @@ export class BubbleSdk {
205
243
  return this.enqueueTurn(sessionId, options);
206
244
  }
207
245
  // ── Discovery (composer pickers) ─────────────────────────────────────────
208
- listSkills(cwd) {
246
+ listSkills(cwd, skillPaths = []) {
209
247
  const registry = new SkillRegistry({
210
248
  cwd: cwd || this.defaultCwd,
211
- skillPaths: this.userConfig.getSkillPaths(),
249
+ skillPaths: [...this.userConfig.getSkillPaths(), ...skillPaths],
212
250
  disabledSkills: this.userConfig.getDisabledSkills(),
213
251
  });
214
252
  return registry.summaries();
@@ -284,10 +322,13 @@ export class BubbleSdk {
284
322
  outcomes: new Map(),
285
323
  options,
286
324
  events: new ReplayEventLog(),
325
+ started: reservation.phase !== "queued",
326
+ inputRecorded: false,
327
+ finished: Promise.resolve(),
287
328
  };
288
329
  this.lastTurnOptions.set(sessionId, inheritableTurnOptions(options));
289
330
  this.turnRuntimes.set(reservation.id, runtime);
290
- void this.pumpTurn(runtime, resolved);
331
+ runtime.finished = this.pumpTurn(runtime, resolved);
291
332
  return runtime;
292
333
  }
293
334
  async pumpTurn(runtime, resolved) {
@@ -296,7 +337,7 @@ export class BubbleSdk {
296
337
  try {
297
338
  for await (const event of this.runReservedTurn(runtime, runtime.options, resolved)) {
298
339
  runtime.events.append(event);
299
- // Publish success after the generator's finally releases its slot.
340
+ // Publish success after the pump's finally releases its slot.
300
341
  // A host can then distinguish idle from an automatically queued steer.
301
342
  if (event.type === "agent_end")
302
343
  completedEvent = event;
@@ -306,8 +347,29 @@ export class BubbleSdk {
306
347
  }
307
348
  catch (error) {
308
349
  failure = error;
350
+ // Setup can fail before runReservedTurn installs its own cleanup.
351
+ for (const event of this.rejectOutstandingSteers(runtime, runtime.reservation.signal.aborted ? "turn_cancelled" : "turn_failed")) {
352
+ runtime.events.append(event);
353
+ this.publishSessionEvent(runtime, event);
354
+ }
355
+ // Desktop users already submitted this input. Even if setup was
356
+ // interrupted before Agent.run appended it, keep it for a later resume.
357
+ // Cancelled queued inputs never owned the turn and must stay discarded.
358
+ if (runtime.options.preserveInterruptedInput && runtime.started && !runtime.inputRecorded
359
+ && runtime.reservation.signal.aborted && !this.turnCoordinator.isDeleted(runtime.sessionId)) {
360
+ try {
361
+ this.persistInterruptedInput(resolved, runtime.options.prompt);
362
+ }
363
+ catch (persistError) {
364
+ failure = persistError;
365
+ }
366
+ }
309
367
  }
310
368
  finally {
369
+ runtime.inputController.closePendingInputs();
370
+ // Release only after the interrupted input is durable. Keep the runtime
371
+ // discoverable until the terminal event is published for stopAndWait.
372
+ runtime.reservation.finish();
311
373
  if (failure !== undefined) {
312
374
  // The session-level log never closes per turn, so replay subscribers
313
375
  // need an explicit terminal record — otherwise a failed turn is
@@ -337,6 +399,8 @@ export class BubbleSdk {
337
399
  const mode = options.mode ?? "default";
338
400
  const planExitMode = options.planExitMode === "bypassPermissions" ? "bypassPermissions" : "default";
339
401
  const abortSignal = reservation.signal;
402
+ await reservation.waitForStart();
403
+ runtime.started = true;
340
404
  let agentRef;
341
405
  let streamCompleted = false;
342
406
  const hookController = new ExternalHookController({ cwd, sessionId });
@@ -399,10 +463,9 @@ export class BubbleSdk {
399
463
  });
400
464
  abortSignal.addEventListener("abort", () => questionController.rejectAll(), { once: true });
401
465
  try {
402
- await reservation.waitForStart();
403
466
  const skillRegistry = new SkillRegistry({
404
467
  cwd,
405
- skillPaths: this.userConfig.getSkillPaths(),
468
+ skillPaths: [...this.userConfig.getSkillPaths(), ...(options.skillPaths ?? [])],
406
469
  disabledSkills: this.userConfig.getDisabledSkills(),
407
470
  });
408
471
  const tools = createAllTools(cwd, skillRegistry, {
@@ -414,6 +477,22 @@ export class BubbleSdk {
414
477
  checkpoints: () => session.getCheckpoints(),
415
478
  });
416
479
  tools.push(...gateMcpTools(await awaitWithAbort(this.mcpToolsFor(cwd), abortSignal), approvalController));
480
+ const hostTools = options.hostTools ?? [];
481
+ const registered = new Set(tools.map(tool => tool.name));
482
+ for (const tool of hostTools) {
483
+ if (registered.has(tool.name))
484
+ throw new Error(`Duplicate host tool: ${tool.name}`);
485
+ registered.add(tool.name);
486
+ const guarded = {
487
+ ...tool,
488
+ execute: async (args, ctx) => {
489
+ throwAbortSignal(abortSignal);
490
+ return tool.execute(args, { ...ctx, abortSignal });
491
+ },
492
+ cloneForChild: () => ({ ...tool, enabled: () => false, execute: async () => ({ content: "Host capability is not available to this child.", isError: true }) }),
493
+ };
494
+ tools.push(...(tool.readOnly ? [guarded] : gateMcpTools([guarded], approvalController)));
495
+ }
417
496
  throwAbortSignal(abortSignal);
418
497
  const promptCacheKey = session.getOrCreatePromptCacheKey();
419
498
  if (!this.turnCoordinator.isDeleted(sessionId)) {
@@ -429,6 +508,8 @@ export class BubbleSdk {
429
508
  const memoryPrompt = buildMemoryPrompt(cwd);
430
509
  const builtSystemPrompt = buildSystemPrompt({
431
510
  agentName: "Bubble",
511
+ skills: skillRegistry.summaries(),
512
+ skillCatalogChars: this.userConfig.getSkillCatalogChars(),
432
513
  configuredProvider: providerId || "none",
433
514
  configuredModel: model ? displayModel(model) : "none",
434
515
  configuredModelId: model || "none",
@@ -487,6 +568,7 @@ export class BubbleSdk {
487
568
  budgetLedger: new BudgetLedger(),
488
569
  fileStateTracker,
489
570
  skills: skillRegistry.summaries(),
571
+ skillCatalogChars: this.userConfig.getSkillCatalogChars(),
490
572
  memoryPrompt,
491
573
  externalHooks: hookController,
492
574
  // Cross-provider subagent routes (spawn_agent with "provider:model")
@@ -500,6 +582,8 @@ export class BubbleSdk {
500
582
  return;
501
583
  if (this.turnCoordinator.isDeleted(sessionId))
502
584
  return;
585
+ if (message.role === "user")
586
+ runtime.inputRecorded = true;
503
587
  persistFenced(() => {
504
588
  session.appendMessage(message, contextRevision);
505
589
  contextRevision = session.getRevision();
@@ -576,9 +660,6 @@ export class BubbleSdk {
576
660
  this.rejectOutstandingSteers(runtime, streamCompleted ? "no_continuation" : abortSignal.aborted ? "turn_cancelled" : "turn_failed", streamCompleted ? resolved : undefined);
577
661
  unsubscribeQuestions();
578
662
  questionController.rejectAll();
579
- reservation.finish();
580
- if (this.turnRuntimes.get(reservation.id) === runtime)
581
- this.turnRuntimes.delete(reservation.id);
582
663
  }
583
664
  }
584
665
  // ── Internals ────────────────────────────────────────────────────────────
package/dist/session.d.ts CHANGED
@@ -114,7 +114,11 @@ export declare class SessionManager {
114
114
  * Lives next to the session JSONL as `<session>.checkpoints/`.
115
115
  */
116
116
  getCheckpoints(): CheckpointStore;
117
- /** Entry id of the most recent user message, or "0" before the first one. */
117
+ /**
118
+ * Entry id of the most recent user turn (as listUserTurns counts them), or
119
+ * "0" before the first one. Checkpoints are keyed by it, so edits made after
120
+ * a tool screenshot or a harness-injected block stay with the user's turn.
121
+ */
118
122
  lastUserEntryId(): string;
119
123
  /** User messages after the latest /clear, oldest first — the valid rewind anchors. */
120
124
  listUserTurns(): UserTurn[];
package/dist/session.js CHANGED
@@ -414,13 +414,20 @@ export class SessionManager {
414
414
  }
415
415
  return this.checkpoints;
416
416
  }
417
- /** Entry id of the most recent user message, or "0" before the first one. */
417
+ /**
418
+ * Entry id of the most recent user turn (as listUserTurns counts them), or
419
+ * "0" before the first one. Checkpoints are keyed by it, so edits made after
420
+ * a tool screenshot or a harness-injected block stay with the user's turn.
421
+ */
418
422
  lastUserEntryId() {
419
423
  const entries = this.log.list();
420
424
  for (let i = entries.length - 1; i >= 0; i--) {
421
425
  const entry = entries[i];
422
- if (entry.type === "user_message")
423
- return entry.id;
426
+ if (entry.type !== "user_message")
427
+ continue;
428
+ if (entry.message.toolObservation || isInternalBlockOnlyContent(entry.message.content))
429
+ continue;
430
+ return entry.id;
424
431
  }
425
432
  return "0";
426
433
  }
@@ -443,7 +450,8 @@ export class SessionManager {
443
450
  continue;
444
451
  // Harness-injected turns (goal kicks, task wakes) are internal blocks —
445
452
  // not rewind anchors, and their markup must never render in the picker.
446
- if (isInternalBlockOnlyContent(entry.message.content))
453
+ // Tool screenshots are user-role only for the provider; not turns either.
454
+ if (isInternalBlockOnlyContent(entry.message.content) || entry.message.toolObservation)
447
455
  continue;
448
456
  const text = messageText(entry.message);
449
457
  turns.push({
@@ -617,7 +625,7 @@ function firstUserEntryAfterLatestClear(entries) {
617
625
  const entry = entries[i];
618
626
  // Skip harness-injected internal blocks (goal kicks, task wakes): they
619
627
  // must not become the /resume preview or the deterministic title.
620
- if (entry.type === "user_message" && !isInternalBlockOnlyContent(entry.message.content)) {
628
+ if (entry.type === "user_message" && !isInternalBlockOnlyContent(entry.message.content) && !entry.message.toolObservation) {
621
629
  return entry;
622
630
  }
623
631
  }
@@ -1,2 +1,32 @@
1
1
  import type { SkillSummary } from "./types.js";
2
- export declare function formatSkillsPrompt(skills: SkillSummary[]): string;
2
+ /** Default size of the always-present skill catalog (the listed lines, not the header). */
3
+ export declare const SKILL_CATALOG_CHARS = 12000;
4
+ /** Each description is cut to this length; it only has to tell the model when to load. */
5
+ export declare const SKILL_CATALOG_DESC_CHARS = 120;
6
+ export declare const SKILL_CATALOG_HEADING = "## Skills";
7
+ /** First line under the heading; together they mark the catalog unambiguously. */
8
+ export declare const SKILL_CATALOG_INTRO = "Skills are task-specific instructions loaded on demand. When a task matches a skill below, call `skill` with its exact name before starting, then follow it. Load only what clearly applies.";
9
+ export interface SkillCatalogOptions {
10
+ /** Budget for the listed lines; 0 turns the catalog off. */
11
+ budgetChars?: number;
12
+ /** skill_search is available, so the overflow line can point to it. */
13
+ searchable?: boolean;
14
+ }
15
+ export interface SkillCatalog {
16
+ /** The system-prompt section, or "" when there is nothing to list. */
17
+ text: string;
18
+ listed: number;
19
+ unlisted: number;
20
+ /** Characters used by the listed lines, against budgetChars. */
21
+ chars: number;
22
+ budgetChars: number;
23
+ }
24
+ /**
25
+ * The always-present skill catalog: name and a one-line description per skill,
26
+ * so the model knows what exists and loads a body with `skill` only when it
27
+ * applies. Ordering is deterministic (source, then name) so the system prompt
28
+ * stays byte-identical, and cacheable, while the skill set is unchanged.
29
+ */
30
+ export declare function buildSkillCatalog(skills: SkillSummary[], options?: SkillCatalogOptions): SkillCatalog;
31
+ /** The catalog section text (see buildSkillCatalog). */
32
+ export declare function formatSkillsPrompt(skills: SkillSummary[], options?: SkillCatalogOptions): string;
@@ -1,47 +1,68 @@
1
- const MAX_SKILL_DESC_CHARS = 200;
2
- const SKILLS_BUDGET_CHARS = 6000;
1
+ /** Default size of the always-present skill catalog (the listed lines, not the header). */
2
+ export const SKILL_CATALOG_CHARS = 12_000;
3
+ /** Each description is cut to this length; it only has to tell the model when to load. */
4
+ export const SKILL_CATALOG_DESC_CHARS = 120;
5
+ export const SKILL_CATALOG_HEADING = "## Skills";
6
+ /** First line under the heading; together they mark the catalog unambiguously. */
7
+ export const SKILL_CATALOG_INTRO = "Skills are task-specific instructions loaded on demand. When a task matches a skill below, call `skill` with its exact name before starting, then follow it. Load only what clearly applies.";
3
8
  const SOURCE_PRIORITY = {
4
9
  project: 0,
5
10
  configured: 1,
6
11
  user: 2,
7
12
  };
8
- export function formatSkillsPrompt(skills) {
9
- if (skills.length === 0)
10
- return "";
13
+ /**
14
+ * The always-present skill catalog: name and a one-line description per skill,
15
+ * so the model knows what exists and loads a body with `skill` only when it
16
+ * applies. Ordering is deterministic (source, then name) so the system prompt
17
+ * stays byte-identical, and cacheable, while the skill set is unchanged.
18
+ */
19
+ export function buildSkillCatalog(skills, options = {}) {
20
+ const budgetChars = Math.max(0, Math.floor(options.budgetChars ?? SKILL_CATALOG_CHARS));
21
+ const empty = { text: "", listed: 0, unlisted: skills.length, chars: 0, budgetChars };
22
+ if (skills.length === 0 || budgetChars === 0)
23
+ return empty;
11
24
  const sorted = [...skills].sort((a, b) => {
12
25
  const ap = SOURCE_PRIORITY[a.source ?? "user"] ?? 3;
13
26
  const bp = SOURCE_PRIORITY[b.source ?? "user"] ?? 3;
14
27
  if (ap !== bp)
15
28
  return ap - bp;
16
- return a.name.localeCompare(b.name);
29
+ // Code-point order, not locale collation: the prompt must be byte-identical across runtimes.
30
+ return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
17
31
  });
18
- const header = [
19
- "Skills provide specialized instructions and workflows for specific tasks.",
20
- "Use the skill system when a task clearly matches a skill description.",
21
- "Available skills:",
22
- ];
23
32
  const lines = [];
24
33
  let used = 0;
25
- let dropped = 0;
26
34
  for (const skill of sorted) {
27
35
  const line = formatSkillLine(skill);
28
- if (used + line.length + 1 > SKILLS_BUDGET_CHARS) {
29
- dropped = sorted.length - lines.length;
36
+ if (used + line.length + 1 > budgetChars)
30
37
  break;
31
- }
32
38
  lines.push(line);
33
39
  used += line.length + 1;
34
40
  }
35
- if (dropped > 0) {
36
- lines.push(`- ... and ${dropped} more skills (use /skills to list all)`);
41
+ if (lines.length === 0)
42
+ return empty;
43
+ const unlisted = sorted.length - lines.length;
44
+ if (unlisted > 0) {
45
+ lines.push(options.searchable
46
+ ? `- … and ${unlisted} more skills not listed here; use skill_search to find them.`
47
+ : `- … and ${unlisted} more skills not listed here.`);
37
48
  }
38
- return [...header, ...lines].join("\n");
49
+ const text = [
50
+ SKILL_CATALOG_HEADING,
51
+ SKILL_CATALOG_INTRO,
52
+ ...lines,
53
+ ].join("\n");
54
+ return { text, listed: sorted.length - unlisted, unlisted, chars: used, budgetChars };
55
+ }
56
+ /** The catalog section text (see buildSkillCatalog). */
57
+ export function formatSkillsPrompt(skills, options = {}) {
58
+ return buildSkillCatalog(skills, options).text;
39
59
  }
40
60
  function formatSkillLine(skill) {
41
- const tagSuffix = skill.tags && skill.tags.length > 0 ? ` [tags: ${skill.tags.join(", ")}]` : "";
42
- const rawDesc = skill.description ?? "";
43
- const desc = rawDesc.length > MAX_SKILL_DESC_CHARS
44
- ? rawDesc.slice(0, MAX_SKILL_DESC_CHARS - 1) + "…"
45
- : rawDesc;
46
- return `- ${skill.name}: ${desc}${tagSuffix}`;
61
+ const raw = (skill.description ?? "").replace(/\s+/g, " ").trim();
62
+ // Cut by code points so an emoji is never split into a lone surrogate, which some APIs reject.
63
+ const points = Array.from(raw);
64
+ const desc = points.length > SKILL_CATALOG_DESC_CHARS
65
+ ? points.slice(0, SKILL_CATALOG_DESC_CHARS - 1).join("").trimEnd() + "…"
66
+ : raw;
67
+ return desc ? `- ${skill.name}: ${desc}` : `- ${skill.name}`;
47
68
  }
@@ -1,4 +1,7 @@
1
+ import type { SlashCommand } from "./types.js";
1
2
  import type { UnifiedCommand } from "./unified.js";
3
+ /** Rebuild the system prompt from the agent's live options (model, tools, memory, Skills catalog). */
4
+ export declare function syncSystemPrompt(ctx: Pick<Parameters<SlashCommand["handler"]>[1], "agent" | "cwd">, model: string): void;
2
5
  /**
3
6
  * Public export — built-in commands tagged with `source: "builtin"` so the
4
7
  * registry and TUI can group them uniformly alongside MCP-derived commands.