@bubblebrain-ai/bubble 0.0.58 → 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 (177) hide show
  1. package/README.md +56 -6
  2. package/dist/agent/child-runner.js +5 -1
  3. package/dist/agent/internal-reminder-sanitizer.d.ts +1 -1
  4. package/dist/agent/internal-reminder-sanitizer.js +7 -1
  5. package/dist/agent/profiles.js +4 -4
  6. package/dist/agent/routing-catalog.d.ts +19 -1
  7. package/dist/agent/routing-catalog.js +32 -13
  8. package/dist/agent/subagent/runtime.d.ts +28 -0
  9. package/dist/agent/subagent/runtime.js +34 -30
  10. package/dist/agent/subagent-control.d.ts +7 -0
  11. package/dist/agent/subagent-control.js +2 -0
  12. package/dist/agent/subagent-lifecycle-reminder.js +2 -1
  13. package/dist/agent/subagent-wait-guidance.d.ts +2 -0
  14. package/dist/agent/subagent-wait-guidance.js +8 -0
  15. package/dist/agent/subtask-policy.js +4 -4
  16. package/dist/agent.d.ts +20 -11
  17. package/dist/agent.js +200 -117
  18. package/dist/anthropic-thinking.d.ts +4 -0
  19. package/dist/anthropic-thinking.js +11 -0
  20. package/dist/approval/controller.d.ts +20 -6
  21. package/dist/approval/controller.js +67 -8
  22. package/dist/approval/protected-paths.d.ts +1 -0
  23. package/dist/approval/protected-paths.js +35 -0
  24. package/dist/approval/session-cache.d.ts +12 -26
  25. package/dist/approval/session-cache.js +20 -50
  26. package/dist/approval/types.d.ts +27 -1
  27. package/dist/cli.d.ts +5 -0
  28. package/dist/cli.js +25 -2
  29. package/dist/config.d.ts +3 -0
  30. package/dist/config.js +7 -0
  31. package/dist/context/checkpoint.d.ts +18 -0
  32. package/dist/context/checkpoint.js +117 -0
  33. package/dist/context/compact-llm.js +7 -0
  34. package/dist/context/compact.d.ts +8 -0
  35. package/dist/context/compact.js +40 -7
  36. package/dist/context/llm-compactor.d.ts +36 -2
  37. package/dist/context/llm-compactor.js +227 -44
  38. package/dist/context/session-write-lock.d.ts +20 -0
  39. package/dist/context/session-write-lock.js +162 -0
  40. package/dist/context/tool-images.d.ts +3 -0
  41. package/dist/context/tool-images.js +35 -0
  42. package/dist/context/usage.js +22 -5
  43. package/dist/feishu/agent-host/run-driver.js +56 -9
  44. package/dist/lsp/service.d.ts +13 -1
  45. package/dist/lsp/service.js +163 -43
  46. package/dist/main.js +82 -16
  47. package/dist/mcp/config.d.ts +4 -0
  48. package/dist/mcp/config.js +17 -0
  49. package/dist/mcp/manager.d.ts +13 -1
  50. package/dist/mcp/manager.js +32 -1
  51. package/dist/mcp/transports.js +11 -0
  52. package/dist/memory/db.d.ts +1 -0
  53. package/dist/memory/db.js +20 -4
  54. package/dist/memory/phase1.js +122 -16
  55. package/dist/memory/prompts.js +3 -0
  56. package/dist/model-catalog.js +19 -3
  57. package/dist/model-config.d.ts +1 -0
  58. package/dist/model-config.js +5 -5
  59. package/dist/model-pricing.js +26 -9
  60. package/dist/network/provider-transport-log.d.ts +12 -0
  61. package/dist/network/provider-transport-log.js +32 -0
  62. package/dist/network/provider-transport.js +6 -1
  63. package/dist/network/retry.d.ts +1 -1
  64. package/dist/network/retry.js +1 -1
  65. package/dist/oauth/grok.d.ts +1 -0
  66. package/dist/oauth/grok.js +20 -16
  67. package/dist/oauth/openai-codex.d.ts +1 -0
  68. package/dist/oauth/openai-codex.js +29 -25
  69. package/dist/oauth/refresh-control.d.ts +12 -0
  70. package/dist/oauth/refresh-control.js +42 -0
  71. package/dist/oauth/storage.d.ts +55 -1
  72. package/dist/oauth/storage.js +281 -16
  73. package/dist/permissions/rule.d.ts +6 -3
  74. package/dist/permissions/rule.js +71 -12
  75. package/dist/permissions/settings.d.ts +28 -1
  76. package/dist/permissions/settings.js +93 -3
  77. package/dist/permissions/shell-command.d.ts +36 -0
  78. package/dist/permissions/shell-command.js +226 -0
  79. package/dist/permissions/trust-prompt.d.ts +10 -0
  80. package/dist/permissions/trust-prompt.js +34 -0
  81. package/dist/permissions/trust.d.ts +26 -0
  82. package/dist/permissions/trust.js +113 -0
  83. package/dist/prompt/compose.d.ts +3 -0
  84. package/dist/prompt/compose.js +24 -7
  85. package/dist/prompt/environment.js +2 -2
  86. package/dist/prompt/reminders.js +1 -1
  87. package/dist/prompt/runtime.js +1 -1
  88. package/dist/provider-anthropic.d.ts +6 -2
  89. package/dist/provider-anthropic.js +98 -18
  90. package/dist/provider-error-record.d.ts +9 -0
  91. package/dist/provider-error-record.js +42 -14
  92. package/dist/provider-grok.js +5 -1
  93. package/dist/provider-openai-codex.d.ts +8 -0
  94. package/dist/provider-openai-codex.js +107 -24
  95. package/dist/provider-registry.d.ts +45 -2
  96. package/dist/provider-registry.js +300 -88
  97. package/dist/provider-transform.js +8 -0
  98. package/dist/provider-zen-errors.d.ts +9 -0
  99. package/dist/provider-zen-errors.js +36 -0
  100. package/dist/provider.js +36 -5
  101. package/dist/sdk/index.d.ts +69 -8
  102. package/dist/sdk/index.js +252 -39
  103. package/dist/sdk/replay-event-log.d.ts +7 -0
  104. package/dist/sdk/replay-event-log.js +66 -16
  105. package/dist/session-context-fence.d.ts +16 -0
  106. package/dist/session-context-fence.js +26 -0
  107. package/dist/session-log.d.ts +11 -2
  108. package/dist/session-log.js +187 -39
  109. package/dist/session-types.d.ts +5 -1
  110. package/dist/session.d.ts +39 -7
  111. package/dist/session.js +341 -93
  112. package/dist/skills/format.d.ts +31 -1
  113. package/dist/skills/format.js +45 -24
  114. package/dist/slash-commands/commands.d.ts +3 -0
  115. package/dist/slash-commands/commands.js +29 -14
  116. package/dist/system-prompt.d.ts +3 -1
  117. package/dist/tasks/manager.d.ts +7 -0
  118. package/dist/tasks/manager.js +97 -18
  119. package/dist/tools/agent-lifecycle.js +11 -7
  120. package/dist/tools/child-tools.d.ts +1 -1
  121. package/dist/tools/child-tools.js +4 -4
  122. package/dist/tools/edit-apply.d.ts +0 -3
  123. package/dist/tools/edit-apply.js +45 -249
  124. package/dist/tools/edit.js +3 -3
  125. package/dist/tools/exit-plan-mode.d.ts +5 -0
  126. package/dist/tools/exit-plan-mode.js +3 -2
  127. package/dist/tools/file-state.d.ts +4 -0
  128. package/dist/tools/file-state.js +12 -3
  129. package/dist/tools/grep.js +20 -1
  130. package/dist/tools/index.d.ts +1 -1
  131. package/dist/tools/index.js +3 -3
  132. package/dist/tools/ls.d.ts +3 -0
  133. package/dist/tools/ls.js +85 -0
  134. package/dist/tools/path-utils.d.ts +6 -0
  135. package/dist/tools/path-utils.js +38 -1
  136. package/dist/tools/sensitive-paths.d.ts +8 -0
  137. package/dist/tools/sensitive-paths.js +85 -3
  138. package/dist/tools/skill.d.ts +2 -0
  139. package/dist/tools/skill.js +34 -2
  140. package/dist/tools/task-tools.js +11 -7
  141. package/dist/tui/app.js +22 -12
  142. package/dist/tui/components/approval-dialog.d.ts +5 -5
  143. package/dist/tui/components/approval-dialog.js +16 -51
  144. package/dist/tui/components/skills-panel.d.ts +4 -0
  145. package/dist/tui/components/skills-panel.js +15 -1
  146. package/dist/tui/components/streaming-message.js +7 -2
  147. package/dist/tui/components/tasks-pane.d.ts +38 -0
  148. package/dist/tui/components/tasks-pane.js +182 -32
  149. package/dist/tui/components/transcript.d.ts +11 -4
  150. package/dist/tui/components/transcript.js +158 -71
  151. package/dist/tui/composer-autocomplete.d.ts +17 -2
  152. package/dist/tui/composer-autocomplete.js +79 -2
  153. package/dist/tui/controller/controller.d.ts +9 -4
  154. package/dist/tui/controller/controller.js +87 -95
  155. package/dist/tui/controller/goal-runtime-controller.js +1 -1
  156. package/dist/tui/controller/session-transition.js +1 -1
  157. package/dist/tui/model/subagent-view.d.ts +11 -19
  158. package/dist/tui/model/subagent-view.js +56 -85
  159. package/dist/tui/model/task-lifecycle.d.ts +57 -0
  160. package/dist/tui/model/task-lifecycle.js +124 -0
  161. package/dist/tui/model/trace-groups.js +10 -9
  162. package/dist/tui/trace-groups.js +10 -9
  163. package/dist/types.d.ts +20 -0
  164. package/dist/variant/variant-resolver.js +2 -0
  165. package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui-alt-screen.d.ts +1 -0
  166. package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui-alt-screen.d.ts.map +1 -1
  167. package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui-alt-screen.js +6 -0
  168. package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui-alt-screen.js.map +1 -1
  169. package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui.d.ts +2 -0
  170. package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui.d.ts.map +1 -1
  171. package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui.js +8 -4
  172. package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui.js.map +1 -1
  173. package/package.json +11 -3
  174. package/dist/prompt/skills.d.ts +0 -2
  175. package/dist/prompt/skills.js +0 -4
  176. package/dist/tools/glob.d.ts +0 -5
  177. package/dist/tools/glob.js +0 -199
package/dist/sdk/index.js CHANGED
@@ -21,8 +21,11 @@ import { AgentRunInputQueue } from "../agent/input-controller.js";
21
21
  import { SessionTurnCoordinator, } from "./session-turn-coordinator.js";
22
22
  import { ReplayEventLog } from "./replay-event-log.js";
23
23
  import { SessionManager } from "../session.js";
24
+ import { isPermissionModeReminder } from "../prompt/reminders.js";
24
25
  import { PermissionAwareApprovalController } from "../approval/controller.js";
25
26
  import { BashAllowlist } from "../approval/session-cache.js";
27
+ import { SettingsManager } from "../permissions/settings.js";
28
+ import { isRepoConfigTrusted, mergedRepoCapabilities, readRepoSettings, trustRepoConfig, } from "../permissions/trust.js";
26
29
  import { createAllTools, buildToolPromptOptions } from "../tools/index.js";
27
30
  import { buildSystemPrompt } from "../system-prompt.js";
28
31
  import { FileStateTracker } from "../tools/file-state.js";
@@ -36,7 +39,7 @@ import { assertProviderModelAllowed } from "../provider-model-policy.js";
36
39
  import { SkillRegistry } from "../skills/registry.js";
37
40
  import { parseSkillInvocation } from "../skills/invocation.js";
38
41
  import { GoalStore } from "../goal/store.js";
39
- import { McpManager } from "../mcp/manager.js";
42
+ import { McpManager, gateMcpTools } from "../mcp/manager.js";
40
43
  import { loadMcpConfig } from "../mcp/config.js";
41
44
  import { ExternalHookController } from "../hooks/controller.js";
42
45
  import { buildMemoryPrompt } from "../memory/store.js";
@@ -49,12 +52,17 @@ export class BubbleSdk {
49
52
  registry = new ProviderRegistry(this.userConfig);
50
53
  defaultCwd;
51
54
  mcpEnabled;
55
+ sessionEventRetention;
52
56
  cwdBySession = new Map();
53
57
  bashAllowlists = new Map();
58
+ sessionGrants = new Map();
54
59
  turnCoordinator = new SessionTurnCoordinator();
55
60
  turnRuntimes = new Map();
56
61
  lastTurnOptions = new Map();
57
62
  mcpToolsByCwd = new Map();
63
+ mcpManagersByCwd = new Map();
64
+ /** Folders whose trust question was already put to the host. */
65
+ projectTrustAsked = new Set();
58
66
  inputIdNonce = randomUUID().slice(0, 6);
59
67
  nextTurnInputPrefix = 0;
60
68
  nextDetachedInputId = 0;
@@ -63,6 +71,10 @@ export class BubbleSdk {
63
71
  constructor(options = {}) {
64
72
  this.defaultCwd = options.defaultCwd || process.env.BUBBLE_CWD || os.homedir();
65
73
  this.mcpEnabled = options.mcp !== false;
74
+ this.sessionEventRetention = options.sessionEventRetention ?? Infinity;
75
+ if (this.sessionEventRetention !== Infinity && (!Number.isSafeInteger(this.sessionEventRetention) || this.sessionEventRetention < 1)) {
76
+ throw new Error('sessionEventRetention must be a positive integer');
77
+ }
66
78
  }
67
79
  // ── Sessions ─────────────────────────────────────────────────────────────
68
80
  listSessions() {
@@ -84,12 +96,13 @@ export class BubbleSdk {
84
96
  if (!resolved)
85
97
  throw new Error(`Unknown session: ${sessionId}`);
86
98
  const ownerKey = resolved.manager.getSessionFile();
87
- const eventLog = sessionEventLogFor(ownerKey);
99
+ const eventLog = sessionEventLogFor(ownerKey, this.sessionEventRetention);
88
100
  const closed = new AbortController();
89
101
  return {
90
102
  id: sessionId,
91
103
  cwd: resolved.cwd,
92
- events: eventLog.iterate({ signal: closed.signal }),
104
+ get latestSequence() { return eventLog.length; },
105
+ get events() { return eventLog.iterate({ signal: closed.signal }); },
93
106
  eventsFrom: (afterSequence) => eventLog.iterate({
94
107
  from: Math.max(0, afterSequence),
95
108
  signal: closed.signal,
@@ -105,6 +118,31 @@ export class BubbleSdk {
105
118
  return [];
106
119
  return this.resolveSession(sessionId)?.manager.getMessages() ?? [];
107
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
+ }
108
146
  async deleteSession(sessionId) {
109
147
  const owner = this.ownerForSession(sessionId);
110
148
  if (owner && owner !== this)
@@ -117,6 +155,7 @@ export class BubbleSdk {
117
155
  rmSync(resolved.manager.getSessionFile(), { force: true });
118
156
  this.cwdBySession.delete(sessionId);
119
157
  this.bashAllowlists.delete(sessionId);
158
+ this.sessionGrants.delete(sessionId);
120
159
  this.lastTurnOptions.delete(sessionId);
121
160
  this.sessionIndex.delete(sessionId);
122
161
  processSessionLocations.delete(sessionId);
@@ -133,8 +172,21 @@ export class BubbleSdk {
133
172
  const current = this.turnCoordinator.getCurrent(sessionId);
134
173
  const runtime = current ? this.turnRuntimes.get(current.id) : undefined;
135
174
  runtime?.inputController.closePendingInputs();
136
- const stopped = this.turnCoordinator.stopCurrent(sessionId);
137
- 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;
138
190
  }
139
191
  /** Cancel queued turns without interrupting the turn that owns the session slot. */
140
192
  clearQueue(sessionId) {
@@ -191,14 +243,36 @@ export class BubbleSdk {
191
243
  return this.enqueueTurn(sessionId, options);
192
244
  }
193
245
  // ── Discovery (composer pickers) ─────────────────────────────────────────
194
- listSkills(cwd) {
246
+ listSkills(cwd, skillPaths = []) {
195
247
  const registry = new SkillRegistry({
196
248
  cwd: cwd || this.defaultCwd,
197
- skillPaths: this.userConfig.getSkillPaths(),
249
+ skillPaths: [...this.userConfig.getSkillPaths(), ...skillPaths],
198
250
  disabledSkills: this.userConfig.getDisabledSkills(),
199
251
  });
200
252
  return registry.summaries();
201
253
  }
254
+ /**
255
+ * Ask the host once per folder (onProjectTrust) before this turn loads the
256
+ * repository's allow rules, MCP servers and LSP servers. Trusting restarts
257
+ * the folder's MCP servers so newly trusted ones are available right away.
258
+ */
259
+ async resolveProjectTrust(cwd, options, signal) {
260
+ if (!options.onProjectTrust || this.projectTrustAsked.has(cwd))
261
+ return;
262
+ // Read once: what gets trusted is exactly what the host was shown.
263
+ const raw = readRepoSettings(cwd);
264
+ if (isRepoConfigTrusted(cwd, raw))
265
+ return;
266
+ this.projectTrustAsked.add(cwd);
267
+ const trusted = await awaitWithAbort(options.onProjectTrust({ cwd, pending: mergedRepoCapabilities(raw) }), signal);
268
+ if (!trusted)
269
+ return;
270
+ trustRepoConfig(cwd, raw);
271
+ const manager = this.mcpManagersByCwd.get(cwd);
272
+ this.mcpManagersByCwd.delete(cwd);
273
+ this.mcpToolsByCwd.delete(cwd);
274
+ await (await manager?.catch(() => null))?.shutdown().catch(() => undefined);
275
+ }
202
276
  /** Configured providers + default model, for a host's model picker. */
203
277
  getModelConfig() {
204
278
  const configured = this.registry.getConfigured().filter((p) => p.enabled);
@@ -248,24 +322,54 @@ export class BubbleSdk {
248
322
  outcomes: new Map(),
249
323
  options,
250
324
  events: new ReplayEventLog(),
325
+ started: reservation.phase !== "queued",
326
+ inputRecorded: false,
327
+ finished: Promise.resolve(),
251
328
  };
252
329
  this.lastTurnOptions.set(sessionId, inheritableTurnOptions(options));
253
330
  this.turnRuntimes.set(reservation.id, runtime);
254
- void this.pumpTurn(runtime, resolved);
331
+ runtime.finished = this.pumpTurn(runtime, resolved);
255
332
  return runtime;
256
333
  }
257
334
  async pumpTurn(runtime, resolved) {
258
335
  let failure;
336
+ let completedEvent;
259
337
  try {
260
338
  for await (const event of this.runReservedTurn(runtime, runtime.options, resolved)) {
261
339
  runtime.events.append(event);
262
- this.publishSessionEvent(runtime, event);
340
+ // Publish success after the pump's finally releases its slot.
341
+ // A host can then distinguish idle from an automatically queued steer.
342
+ if (event.type === "agent_end")
343
+ completedEvent = event;
344
+ else
345
+ this.publishSessionEvent(runtime, event);
263
346
  }
264
347
  }
265
348
  catch (error) {
266
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
+ }
267
367
  }
268
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();
269
373
  if (failure !== undefined) {
270
374
  // The session-level log never closes per turn, so replay subscribers
271
375
  // need an explicit terminal record — otherwise a failed turn is
@@ -277,6 +381,9 @@ export class BubbleSdk {
277
381
  message: failure instanceof Error ? failure.message : String(failure),
278
382
  });
279
383
  }
384
+ else {
385
+ this.publishSessionEvent(runtime, completedEvent ?? { type: "agent_end" }, { kind: "completed" });
386
+ }
280
387
  runtime.events.close(failure);
281
388
  if (this.turnRuntimes.get(runtime.reservation.id) === runtime) {
282
389
  this.turnRuntimes.delete(runtime.reservation.id);
@@ -290,10 +397,18 @@ export class BubbleSdk {
290
397
  const { sessionId, reservation, inputController } = runtime;
291
398
  const { manager: session, cwd } = resolved;
292
399
  const mode = options.mode ?? "default";
400
+ const planExitMode = options.planExitMode === "bypassPermissions" ? "bypassPermissions" : "default";
293
401
  const abortSignal = reservation.signal;
402
+ await reservation.waitForStart();
403
+ runtime.started = true;
294
404
  let agentRef;
295
405
  let streamCompleted = false;
296
406
  const hookController = new ExternalHookController({ cwd, sessionId });
407
+ await this.resolveProjectTrust(cwd, options, abortSignal);
408
+ // Same allow/deny rules as the TUI (user, project and local settings;
409
+ // repository allow rules only once trusted). Deny rules bind even under
410
+ // bypassPermissions.
411
+ const ruleSet = new SettingsManager(cwd).getMerged().ruleSet;
297
412
  // Settles as reject the moment the turn aborts, so a tool blocked on a
298
413
  // host approval (or question) can never hang the abort path.
299
414
  const abortedDecision = new Promise((resolve) => {
@@ -314,7 +429,10 @@ export class BubbleSdk {
314
429
  }),
315
430
  },
316
431
  bashAllowlist: this.bashAllowlistFor(sessionId),
432
+ sessionGrants: this.sessionGrantsFor(sessionId),
317
433
  cwd,
434
+ // Read once per turn: edits to settings files apply from the next turn.
435
+ getRuleSet: () => ruleSet,
318
436
  externalHooks: hookController,
319
437
  });
320
438
  const fileStateTracker = new FileStateTracker(cwd);
@@ -324,13 +442,13 @@ export class BubbleSdk {
324
442
  const approved = options.onPlanApproval
325
443
  ? await awaitWithAbort(options.onPlanApproval(plan), abortSignal)
326
444
  : false;
327
- if (approved) {
328
- agentRef?.setMode("default");
329
- return { action: "approve", plan };
330
- }
331
- return { action: "reject", reason: "Plan rejected by host" };
445
+ // exit_plan_mode applies getExitMode() itself on approval.
446
+ return approved
447
+ ? { action: "approve", plan }
448
+ : { action: "reject", reason: "Plan rejected by host" };
332
449
  },
333
450
  setMode: (m) => agentRef?.setMode(m),
451
+ getExitMode: () => planExitMode,
334
452
  };
335
453
  const questionController = new QuestionController();
336
454
  const unsubscribeQuestions = questionController.subscribe((event) => {
@@ -345,10 +463,9 @@ export class BubbleSdk {
345
463
  });
346
464
  abortSignal.addEventListener("abort", () => questionController.rejectAll(), { once: true });
347
465
  try {
348
- await reservation.waitForStart();
349
466
  const skillRegistry = new SkillRegistry({
350
467
  cwd,
351
- skillPaths: this.userConfig.getSkillPaths(),
468
+ skillPaths: [...this.userConfig.getSkillPaths(), ...(options.skillPaths ?? [])],
352
469
  disabledSkills: this.userConfig.getDisabledSkills(),
353
470
  });
354
471
  const tools = createAllTools(cwd, skillRegistry, {
@@ -359,7 +476,23 @@ export class BubbleSdk {
359
476
  goalStore: new GoalStore(),
360
477
  checkpoints: () => session.getCheckpoints(),
361
478
  });
362
- tools.push(...(await awaitWithAbort(this.mcpToolsFor(cwd), abortSignal)));
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
+ }
363
496
  throwAbortSignal(abortSignal);
364
497
  const promptCacheKey = session.getOrCreatePromptCacheKey();
365
498
  if (!this.turnCoordinator.isDeleted(sessionId)) {
@@ -375,6 +508,8 @@ export class BubbleSdk {
375
508
  const memoryPrompt = buildMemoryPrompt(cwd);
376
509
  const builtSystemPrompt = buildSystemPrompt({
377
510
  agentName: "Bubble",
511
+ skills: skillRegistry.summaries(),
512
+ skillCatalogChars: this.userConfig.getSkillCatalogChars(),
378
513
  configuredProvider: providerId || "none",
379
514
  configuredModel: model ? displayModel(model) : "none",
380
515
  configuredModelId: model || "none",
@@ -387,6 +522,39 @@ export class BubbleSdk {
387
522
  const systemPrompt = options.appendSystemPrompt
388
523
  ? `${builtSystemPrompt}\n\n${options.appendSystemPrompt.trim()}`
389
524
  : builtSystemPrompt;
525
+ // Host-driven mode changes (picker, /plan, leaving plan) go into the log
526
+ // as markers, so the rebuilt history states the mode where it changed —
527
+ // after any older tool result that claimed a different mode.
528
+ if (latestRecordedMode(session) !== mode && !this.turnCoordinator.isDeleted(sessionId)) {
529
+ session.appendMarker("mode_switch", mode);
530
+ }
531
+ const history = session.getMessages();
532
+ let contextRevision = session.getRevision();
533
+ // The constructor's leading context minus its mode reminder: the history
534
+ // carries the mode timeline itself (session-log toMessages).
535
+ let leadingContext = [];
536
+ // A fence rejection means the durable log diverged from this turn's
537
+ // resident snapshot (a foreign append or clear landed mid-flight). Refuse
538
+ // the write, then roll the resident transcript back to the file's truth so
539
+ // the rejected message does not linger and later writes are not wedged
540
+ // behind the stale revision.
541
+ const persistFenced = (write) => {
542
+ try {
543
+ write();
544
+ }
545
+ catch (error) {
546
+ // Any refused write (fence rejection, busy lock, I/O) leaves the agent's
547
+ // already-pushed message unpersisted. getMessages() refreshes, so read
548
+ // the history first and adopt the revision of that same snapshot.
549
+ try {
550
+ const reloaded = session.getMessages();
551
+ contextRevision = session.getRevision();
552
+ agent.messages = [...leadingContext, ...reloaded];
553
+ }
554
+ catch { /* surface the original write failure */ }
555
+ throw error;
556
+ }
557
+ };
390
558
  const agent = new Agent({
391
559
  provider,
392
560
  providerId,
@@ -400,6 +568,7 @@ export class BubbleSdk {
400
568
  budgetLedger: new BudgetLedger(),
401
569
  fileStateTracker,
402
570
  skills: skillRegistry.summaries(),
571
+ skillCatalogChars: this.userConfig.getSkillCatalogChars(),
403
572
  memoryPrompt,
404
573
  externalHooks: hookController,
405
574
  // Cross-provider subagent routes (spawn_agent with "provider:model")
@@ -413,29 +582,47 @@ export class BubbleSdk {
413
582
  return;
414
583
  if (this.turnCoordinator.isDeleted(sessionId))
415
584
  return;
416
- session.appendMessage(message);
585
+ if (message.role === "user")
586
+ runtime.inputRecorded = true;
587
+ persistFenced(() => {
588
+ session.appendMessage(message, contextRevision);
589
+ contextRevision = session.getRevision();
590
+ });
417
591
  if (message.role === "assistant")
418
592
  recordMemoryCitations(cwd, message.content);
419
593
  },
420
594
  onProviderError: (error) => {
421
595
  if (this.turnCoordinator.isDeleted(sessionId))
422
596
  return;
423
- session.appendProviderError(error);
597
+ persistFenced(() => session.appendProviderError(error, contextRevision));
424
598
  },
425
- onCompactionApplied: (summary) => {
599
+ getContextRevision: () => contextRevision,
600
+ onContextCheckpoint: (checkpoint) => {
426
601
  if (this.turnCoordinator.isDeleted(sessionId))
427
- return;
428
- session.applyLLMCompaction(summary);
602
+ throw new Error("Session deleted before context commit");
603
+ persistFenced(() => {
604
+ session.commitContextCheckpoint(checkpoint, contextRevision);
605
+ contextRevision = session.getRevision();
606
+ });
429
607
  },
430
608
  onModeUpdate: (m) => {
431
- if (!this.turnCoordinator.isDeleted(sessionId))
432
- session.appendMarker("mode_switch", m);
609
+ if (!this.turnCoordinator.isDeleted(sessionId)) {
610
+ persistFenced(() => {
611
+ session.appendMarker("mode_switch", m, contextRevision);
612
+ contextRevision = session.getRevision();
613
+ });
614
+ }
433
615
  },
434
616
  });
435
617
  agentRef = agent;
436
- const history = session.getMessages();
618
+ leadingContext = agent.messages.filter((message) => !(message.role === "meta" && isPermissionModeReminder(message.content)));
437
619
  if (history.length > 0) {
438
- agent.messages = [{ role: "system", content: systemPrompt }, ...history];
620
+ // Leading context (system prompt + deferred-tools reminder, never
621
+ // persisted) stays ahead of the history, at the same position every
622
+ // turn, so the provider's prefix cache covers the previous turn. The
623
+ // mode reminder comes from the history's own mode markers instead, so
624
+ // it is ordered after anything older that mentioned a mode.
625
+ agent.messages = [...leadingContext, ...history];
439
626
  }
440
627
  reservation.markActive();
441
628
  options.onStart?.({
@@ -473,9 +660,6 @@ export class BubbleSdk {
473
660
  this.rejectOutstandingSteers(runtime, streamCompleted ? "no_continuation" : abortSignal.aborted ? "turn_cancelled" : "turn_failed", streamCompleted ? resolved : undefined);
474
661
  unsubscribeQuestions();
475
662
  questionController.rejectAll();
476
- reservation.finish();
477
- if (this.turnRuntimes.get(reservation.id) === runtime)
478
- this.turnRuntimes.delete(reservation.id);
479
663
  }
480
664
  }
481
665
  // ── Internals ────────────────────────────────────────────────────────────
@@ -578,7 +762,7 @@ export class BubbleSdk {
578
762
  }
579
763
  }
580
764
  publishSessionEvent(runtime, event, terminal) {
581
- const log = sessionEventLogFor(runtime.ownerKey);
765
+ const log = sessionEventLogFor(runtime.ownerKey, this.sessionEventRetention);
582
766
  log.append({
583
767
  sequence: log.length + 1,
584
768
  sessionId: runtime.sessionId,
@@ -599,6 +783,14 @@ export class BubbleSdk {
599
783
  }
600
784
  return allowlist;
601
785
  }
786
+ sessionGrantsFor(sessionId) {
787
+ let grants = this.sessionGrants.get(sessionId);
788
+ if (!grants) {
789
+ grants = new Set();
790
+ this.sessionGrants.set(sessionId, grants);
791
+ }
792
+ return grants;
793
+ }
602
794
  /**
603
795
  * Default cross-provider subagent factory over this SDK instance's own
604
796
  * registry — the same semantics as the TUI's createProviderForRoute. The
@@ -612,10 +804,10 @@ export class BubbleSdk {
612
804
  }
613
805
  // getConfigured() reads config.json from disk every call, so keys
614
806
  // added after SDK construction are picked up without a reload call.
615
- if (this.registry.supportsOAuth(providerId) && this.registry.getAuthStorage().has(providerId)) {
807
+ const target = this.registry.getConfigured().find((item) => item.id === providerId);
808
+ if (target?.authType === "oauth") {
616
809
  await this.registry.prepareProvider(providerId);
617
810
  }
618
- const target = this.registry.getConfigured().find((item) => item.id === providerId);
619
811
  if (!target?.enabled || !target.apiKey) {
620
812
  throw new Error(`Subagent route requires provider "${providerId}", but it is not configured or has no active credentials.`);
621
813
  }
@@ -630,20 +822,25 @@ export class BubbleSdk {
630
822
  });
631
823
  };
632
824
  }
633
- /** MCP servers are started lazily, once per cwd (McpManager has no stop). */
825
+ /**
826
+ * MCP servers are started lazily, once per cwd, and restarted only when the
827
+ * repository's settings get trusted (resolveProjectTrust).
828
+ */
634
829
  mcpToolsFor(cwd) {
635
830
  if (!this.mcpEnabled)
636
831
  return Promise.resolve([]);
637
832
  let cached = this.mcpToolsByCwd.get(cwd);
638
833
  if (!cached) {
639
- cached = (async () => {
834
+ const managerPromise = (async () => {
640
835
  const loaded = loadMcpConfig({ cwd });
641
836
  if (loaded.servers.length === 0)
642
- return [];
837
+ return null;
643
838
  const manager = new McpManager({ servers: loaded.servers });
644
839
  await manager.start();
645
- return manager.getToolEntries();
646
- })().catch(() => []);
840
+ return manager;
841
+ })().catch(() => null);
842
+ this.mcpManagersByCwd.set(cwd, managerPromise);
843
+ cached = managerPromise.then((manager) => manager?.getToolEntries() ?? []);
647
844
  this.mcpToolsByCwd.set(cwd, cached);
648
845
  }
649
846
  return cached;
@@ -706,6 +903,10 @@ export class BubbleSdk {
706
903
  promptCacheKey,
707
904
  protocol: target.protocol,
708
905
  headers: target.headers,
906
+ // Main SDK turns need the same per-request OAuth renewal as the TUI
907
+ // and child provider factory, including calls after a long-running tool.
908
+ openAICodexAuth: this.registry.createOpenAICodexAuthAdapter(activeProviderId),
909
+ grokAuth: this.registry.createGrokAuthAdapter(activeProviderId),
709
910
  });
710
911
  return { provider, providerId: activeProviderId, model: activeModel };
711
912
  }
@@ -730,10 +931,10 @@ function releaseProcessOwner(ownerKey, sdk) {
730
931
  if (processSessionOwners.get(ownerKey) === sdk)
731
932
  processSessionOwners.delete(ownerKey);
732
933
  }
733
- function sessionEventLogFor(ownerKey) {
934
+ function sessionEventLogFor(ownerKey, retention = Infinity) {
734
935
  let log = sessionEventLogs.get(ownerKey);
735
936
  if (!log) {
736
- log = new ReplayEventLog();
937
+ log = new ReplayEventLog(retention);
737
938
  sessionEventLogs.set(ownerKey, log);
738
939
  }
739
940
  return log;
@@ -767,6 +968,18 @@ function throwAbortSignal(signal) {
767
968
  throw signal.reason;
768
969
  throw new AgentAbortError(typeof signal.reason === "string" ? signal.reason : "SDK turn cancelled.");
769
970
  }
971
+ /** The mode the session log last recorded ("default" before any switch). */
972
+ function latestRecordedMode(session) {
973
+ const entries = session.getEntries();
974
+ for (let index = entries.length - 1; index >= 0; index--) {
975
+ const entry = entries[index];
976
+ if (entry.type === "marker" && entry.kind === "mode_switch"
977
+ && (entry.value === "default" || entry.value === "plan" || entry.value === "bypassPermissions")) {
978
+ return entry.value;
979
+ }
980
+ }
981
+ return "default";
982
+ }
770
983
  function awaitWithAbort(promise, signal) {
771
984
  if (signal.aborted) {
772
985
  try {
@@ -3,11 +3,17 @@
3
3
  * consumer, and consumers can attach again with their last seen sequence.
4
4
  */
5
5
  export declare class ReplayEventLog<T> {
6
+ private readonly retention;
6
7
  private readonly entries;
8
+ private nextIndex;
9
+ private firstIndex;
10
+ private readonly readers;
7
11
  private signal;
8
12
  private closed;
9
13
  private failure;
14
+ constructor(retention?: number);
10
15
  get length(): number;
16
+ get retainedCount(): number;
11
17
  append(value: T): void;
12
18
  close(error?: unknown): void;
13
19
  iterate(options?: {
@@ -15,5 +21,6 @@ export declare class ReplayEventLog<T> {
15
21
  signal?: AbortSignal;
16
22
  }): AsyncGenerator<T>;
17
23
  private readFrom;
24
+ private prune;
18
25
  private wake;
19
26
  }