@bubblebrain-ai/bubble 0.0.59 → 0.0.60

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 (47) 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/permissions/shell-command.js +35 -3
  17. package/dist/prompt/compose.d.ts +3 -0
  18. package/dist/prompt/compose.js +17 -3
  19. package/dist/provider-openai-codex.d.ts +4 -0
  20. package/dist/provider-openai-codex.js +28 -9
  21. package/dist/provider-registry.js +4 -2
  22. package/dist/sdk/index.d.ts +21 -2
  23. package/dist/sdk/index.js +92 -11
  24. package/dist/session.d.ts +5 -1
  25. package/dist/session.js +13 -5
  26. package/dist/skills/format.d.ts +31 -1
  27. package/dist/skills/format.js +45 -24
  28. package/dist/slash-commands/commands.d.ts +3 -0
  29. package/dist/slash-commands/commands.js +6 -4
  30. package/dist/system-prompt.d.ts +3 -1
  31. package/dist/tools/edit-apply.d.ts +0 -3
  32. package/dist/tools/edit-apply.js +45 -249
  33. package/dist/tools/edit.js +3 -3
  34. package/dist/tools/grep.js +20 -1
  35. package/dist/tools/skill.d.ts +2 -0
  36. package/dist/tools/skill.js +34 -2
  37. package/dist/tui/app.js +6 -0
  38. package/dist/tui/components/skills-panel.d.ts +4 -0
  39. package/dist/tui/components/skills-panel.js +15 -1
  40. package/dist/tui/components/tasks-pane.d.ts +38 -0
  41. package/dist/tui/components/tasks-pane.js +182 -32
  42. package/dist/tui/controller/controller.d.ts +7 -0
  43. package/dist/tui/controller/controller.js +13 -0
  44. package/dist/types.d.ts +7 -0
  45. package/package.json +1 -1
  46. package/dist/prompt/skills.d.ts +0 -2
  47. package/dist/prompt/skills.js +0 -4
@@ -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.
@@ -182,16 +182,18 @@ function persistSelectedModel(model, ctx) {
182
182
  ctx.sessionManager.appendMarker("model_switch", model);
183
183
  }
184
184
  }
185
- function syncSystemPrompt(ctx, model) {
185
+ /** Rebuild the system prompt from the agent's live options (model, tools, memory, Skills catalog). */
186
+ export function syncSystemPrompt(ctx, model) {
186
187
  const { providerId } = decodeModel(model);
187
188
  const toolPromptOptions = typeof ctx.agent.getSystemPromptToolOptions === "function"
188
189
  ? ctx.agent.getSystemPromptToolOptions()
189
190
  : {};
190
191
  ctx.agent.setSystemPrompt(buildSystemPrompt({
191
192
  agentName: "Bubble",
192
- configuredProvider: providerId,
193
- configuredModel: displayModel(model),
194
- configuredModelId: model,
193
+ // Same placeholders as startup when no model is configured yet.
194
+ configuredProvider: providerId || "none",
195
+ configuredModel: model ? displayModel(model) : "none",
196
+ configuredModelId: model || "none",
195
197
  thinkingLevel: ctx.agent.thinking,
196
198
  workingDir: ctx.cwd,
197
199
  ...toolPromptOptions,
@@ -26,8 +26,10 @@ export interface SystemPromptOptions {
26
26
  mode?: PermissionMode;
27
27
  /** Current date override */
28
28
  currentDate?: string;
29
- /** Prompt-visible skills */
29
+ /** Prompt-visible skills, listed as the Skills catalog when `skill` is a tool */
30
30
  skills?: SkillSummary[];
31
+ /** Catalog budget in characters; 0 turns the catalog off */
32
+ skillCatalogChars?: number;
31
33
  /** Prompt-visible memory guidance and summaries */
32
34
  memoryPrompt?: string;
33
35
  /** Durable child-agent profile prompt used for subagents. */
@@ -2,13 +2,11 @@ export interface EditOperation {
2
2
  oldText: string;
3
3
  newText: string;
4
4
  }
5
- export type EditMatchMode = "exact" | "trimmed" | "unescaped" | "normalized-line" | "smart-line" | "markdown-table" | "single-line-whitespace";
6
5
  export interface EditApplyOptions {
7
6
  path?: string;
8
7
  }
9
8
  export interface EditMatchInfo {
10
9
  editIndex: number;
11
- mode: EditMatchMode;
12
10
  start: number;
13
11
  end: number;
14
12
  }
@@ -25,4 +23,3 @@ export declare class EditApplyError extends Error {
25
23
  constructor(message: string, status?: "no_match" | "blocked");
26
24
  }
27
25
  export declare function applyEditsToContent(rawContent: string, edits: EditOperation[], options?: EditApplyOptions): AppliedEditResult;
28
- export declare function formatEditMatchNotes(matches: EditMatchInfo[]): string;