@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
@@ -2,11 +2,38 @@ import { execFile as execFileCallback, spawn } from "node:child_process";
2
2
  import { EventEmitter } from "node:events";
3
3
  import { access, readFile } from "node:fs/promises";
4
4
  import { createRequire } from "node:module";
5
- import { basename, dirname, extname, isAbsolute, join, normalize, relative, resolve } from "node:path";
5
+ import { basename, dirname, extname, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
6
6
  import { promisify } from "node:util";
7
7
  import { pathToFileURL, fileURLToPath } from "node:url";
8
8
  import { createMessageConnection, StreamMessageReader, StreamMessageWriter, } from "vscode-jsonrpc/node.js";
9
9
  import { customLspServerEntries, isLspEnabled, isLspServerEnabled } from "./config.js";
10
+ // vscode-jsonrpc 8.x rethrows write failures inside an async Promise executor.
11
+ // Dispose through the lifecycle handler instead: pending requests still reject,
12
+ // without an additional unhandled rejection that can crash the desktop host.
13
+ class LspMessageWriter extends StreamMessageWriter {
14
+ onWriteFailure;
15
+ constructor(stream, onWriteFailure) {
16
+ super(stream);
17
+ this.onWriteFailure = onWriteFailure;
18
+ }
19
+ async write(message) {
20
+ try {
21
+ await super.write(message);
22
+ }
23
+ catch (error) {
24
+ this.onWriteFailure(error);
25
+ }
26
+ }
27
+ }
28
+ // Electron's executable is also a Node runtime, but only with this child-local
29
+ // flag. Preserve it for descendants (e.g. typescript-language-server's tsserver).
30
+ function spawnNodeServer(args, cwd) {
31
+ return spawn(process.execPath, args, {
32
+ cwd,
33
+ env: process.versions.electron ? { ...process.env, ELECTRON_RUN_AS_NODE: "1" } : process.env,
34
+ stdio: "pipe",
35
+ });
36
+ }
10
37
  const execFile = promisify(execFileCallback);
11
38
  const services = new Map();
12
39
  const TS_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"];
@@ -14,9 +41,12 @@ const NODE_ROOT_MARKERS = ["package-lock.json", "bun.lockb", "bun.lock", "pnpm-l
14
41
  const DENO_ROOT_MARKERS = ["deno.json", "deno.jsonc"];
15
42
  export class ProjectLspService {
16
43
  cwd;
44
+ timeouts;
17
45
  emitter = new EventEmitter();
18
46
  clients = new Map();
19
47
  spawning = new Map();
48
+ initializing = new Map();
49
+ generation = 0;
20
50
  starting = new Map();
21
51
  broken = new Map();
22
52
  unavailable = new Set();
@@ -24,8 +54,9 @@ export class ProjectLspService {
24
54
  waiters = new Set();
25
55
  disposed = false;
26
56
  config;
27
- constructor(cwd, config) {
57
+ constructor(cwd, config, timeouts = {}) {
28
58
  this.cwd = cwd;
59
+ this.timeouts = timeouts;
29
60
  this.config = config;
30
61
  }
31
62
  updateConfig(config) {
@@ -122,13 +153,12 @@ export class ProjectLspService {
122
153
  return [];
123
154
  const clients = await this.getClients(file);
124
155
  await Promise.all(clients.map((client) => this.openOrChange(client, file)));
125
- const results = await Promise.all(clients.map((client) => client.connection
126
- .sendRequest("textDocument/documentSymbol", { textDocument: { uri: pathToFileURL(file).href } })
156
+ const results = await Promise.all(clients.map((client) => this.request(client, "textDocument/documentSymbol", { textDocument: { uri: pathToFileURL(file).href } })
127
157
  .catch(() => [])));
128
158
  return results.flatMap(normalizeLspResult);
129
159
  }
130
160
  async workspaceSymbol(query) {
131
- const results = await Promise.all([...this.clients.values()].map((client) => client.connection.sendRequest("workspace/symbol", { query }).catch(() => [])));
161
+ const results = await Promise.all([...this.clients.values()].map((client) => this.request(client, "workspace/symbol", { query }).catch(() => [])));
132
162
  return results.flatMap(normalizeLspResult).slice(0, 50);
133
163
  }
134
164
  async prepareCallHierarchy(input) {
@@ -157,12 +187,11 @@ export class ProjectLspService {
157
187
  for (const waiter of this.waiters)
158
188
  waiter.resolve();
159
189
  this.waiters.clear();
160
- for (const client of this.clients.values()) {
161
- client.stopping = true;
162
- client.connection.dispose();
163
- client.process.kill();
164
- }
165
- this.clients.clear();
190
+ this.disposed = true;
191
+ this.generation += 1;
192
+ for (const client of [...this.initializing.values(), ...this.clients.values()])
193
+ this.stopClient(client);
194
+ this.starting.clear();
166
195
  }
167
196
  async runRequest(input, method, params) {
168
197
  const file = this.resolveInsideCwd(input.file);
@@ -170,7 +199,7 @@ export class ProjectLspService {
170
199
  return [];
171
200
  const clients = await this.getClients(file);
172
201
  await Promise.all(clients.map((client) => this.openOrChange(client, file)));
173
- const results = await Promise.all(clients.map((client) => client.connection.sendRequest(method, params).catch(() => [])));
202
+ const results = await Promise.all(clients.map((client) => this.request(client, method, params).catch(() => [])));
174
203
  return results.flatMap(normalizeLspResult);
175
204
  }
176
205
  async callHierarchyRequest(input, method) {
@@ -180,8 +209,7 @@ export class ProjectLspService {
180
209
  const clients = await this.getClients(file);
181
210
  const results = await Promise.all(clients.map(async (client) => {
182
211
  await this.openOrChange(client, file);
183
- const items = await client.connection
184
- .sendRequest("textDocument/prepareCallHierarchy", {
212
+ const items = await this.request(client, "textDocument/prepareCallHierarchy", {
185
213
  textDocument: { uri: pathToFileURL(file).href },
186
214
  position: { line: input.line, character: input.character },
187
215
  })
@@ -189,16 +217,20 @@ export class ProjectLspService {
189
217
  const first = normalizeLspResult(items)[0];
190
218
  if (!first)
191
219
  return [];
192
- return client.connection.sendRequest(method, { item: first }).catch(() => []);
220
+ return this.request(client, method, { item: first }).catch(() => []);
193
221
  }));
194
222
  return results.flatMap(normalizeLspResult);
195
223
  }
196
224
  async getClients(file) {
225
+ if (this.disposed)
226
+ return [];
197
227
  const matches = await this.matchingServers(file);
198
228
  const clients = await Promise.all(matches.map(({ server, root }) => this.getClient(server, root)));
199
229
  return clients.filter((client) => !!client);
200
230
  }
201
231
  async getClient(server, root) {
232
+ if (this.disposed)
233
+ return undefined;
202
234
  const key = `${root}:${server.id}`;
203
235
  const existing = this.clients.get(key);
204
236
  if (existing)
@@ -221,27 +253,36 @@ export class ProjectLspService {
221
253
  const task = this.spawnClient(server, root, key);
222
254
  this.spawning.set(key, task);
223
255
  task.finally(() => {
224
- if (this.spawning.get(key) === task)
256
+ if (this.spawning.get(key) === task) {
225
257
  this.spawning.delete(key);
226
- if (this.starting.delete(key))
227
- this.emitStatus();
258
+ if (this.starting.delete(key))
259
+ this.emitStatus();
260
+ }
228
261
  });
229
262
  return task;
230
263
  }
231
264
  async spawnClient(server, root, key) {
265
+ const generation = this.generation;
266
+ let client;
232
267
  try {
233
268
  const handle = await server.spawn(root, { cwd: this.cwd });
234
269
  if (!handle) {
235
270
  this.unavailable.add(key);
236
271
  return undefined;
237
272
  }
238
- const connection = createMessageConnection(new StreamMessageReader(handle.process.stdout), new StreamMessageWriter(handle.process.stdin), {
273
+ if (this.disposed || generation !== this.generation || !isLspServerEnabled(this.config, server.id)) {
274
+ // Install an error handler even when shutdown won the spawn race.
275
+ handle.process.on("error", () => { });
276
+ handle.process.kill();
277
+ return undefined;
278
+ }
279
+ const connection = createMessageConnection(new StreamMessageReader(handle.process.stdout), new LspMessageWriter(handle.process.stdin, (error) => client?.fail(`server write failed: ${String(error)}`)), {
239
280
  error: () => { },
240
281
  warn: () => { },
241
282
  info: () => { },
242
283
  log: () => { },
243
284
  });
244
- const client = {
285
+ client = {
245
286
  key,
246
287
  id: server.id,
247
288
  name: server.name,
@@ -251,7 +292,22 @@ export class ProjectLspService {
251
292
  connection,
252
293
  documents: new Map(),
253
294
  stopping: false,
295
+ fail: (message) => {
296
+ if (client.stopping)
297
+ return;
298
+ this.stopClient(client);
299
+ if (!this.disposed && generation === this.generation)
300
+ this.markBroken(key, server, root, message);
301
+ },
254
302
  };
303
+ const activeClient = client;
304
+ this.initializing.set(key, client);
305
+ // Drain stderr: an unread pipe can block an otherwise healthy server.
306
+ handle.process.stderr.resume();
307
+ handle.process.once("error", (error) => activeClient.fail(`server error: ${error.message}`));
308
+ handle.process.once("exit", (code, signal) => activeClient.fail(`server exited${code === null ? "" : ` with code ${code}`}${signal ? ` (${signal})` : ""}`));
309
+ connection.onClose(() => activeClient.fail("server connection closed"));
310
+ connection.onError(([error]) => activeClient.fail(`server connection error: ${error.message}`));
255
311
  connection.onNotification("textDocument/publishDiagnostics", (params) => {
256
312
  if (!params?.uri)
257
313
  return;
@@ -265,14 +321,8 @@ export class ProjectLspService {
265
321
  connection.onRequest("workspace/workspaceFolders", () => [{ uri: pathToFileURL(root).href, name: basename(root) }]);
266
322
  connection.onRequest("client/registerCapability", () => null);
267
323
  connection.onRequest("client/unregisterCapability", () => null);
268
- handle.process.once("exit", (code, signal) => {
269
- this.clients.delete(key);
270
- if (!this.disposed && !client.stopping) {
271
- this.markBroken(key, server, root, `server exited${code === null ? "" : ` with code ${code}`}${signal ? ` (${signal})` : ""}`);
272
- }
273
- });
274
324
  connection.listen();
275
- await connection.sendRequest("initialize", {
325
+ await this.request(client, "initialize", {
276
326
  processId: process.pid,
277
327
  rootPath: root,
278
328
  rootUri: pathToFileURL(root).href,
@@ -296,16 +346,74 @@ export class ProjectLspService {
296
346
  },
297
347
  window: { workDoneProgress: false },
298
348
  },
299
- });
300
- connection.sendNotification("initialized", {});
349
+ }, this.timeouts.initializeMs ?? 10_000);
350
+ if (client.stopping || this.disposed || generation !== this.generation)
351
+ return undefined;
352
+ await this.notify(client, "initialized", {});
353
+ if (client.stopping || this.disposed || generation !== this.generation)
354
+ return undefined;
301
355
  this.clients.set(key, client);
302
356
  this.emitStatus();
303
357
  return client;
304
358
  }
305
359
  catch (error) {
306
- this.markBroken(key, server, root, error instanceof Error ? error.message : String(error));
360
+ if (client)
361
+ client.fail(error instanceof Error ? error.message : String(error));
362
+ else if (!this.disposed && generation === this.generation) {
363
+ this.markBroken(key, server, root, error instanceof Error ? error.message : String(error));
364
+ }
307
365
  return undefined;
308
366
  }
367
+ finally {
368
+ if (this.initializing.get(key) === client)
369
+ this.initializing.delete(key);
370
+ }
371
+ }
372
+ async request(client, method, params, timeoutMs = this.timeouts.requestMs ?? 10_000) {
373
+ return this.withDeadline(client, method, () => client.connection.sendRequest(method, params), timeoutMs);
374
+ }
375
+ async notify(client, method, params) {
376
+ await this.withDeadline(client, method, () => client.connection.sendNotification(method, params));
377
+ }
378
+ async withDeadline(client, method, operation, timeoutMs = this.timeouts.requestMs ?? 10_000) {
379
+ let timer;
380
+ try {
381
+ return await Promise.race([
382
+ operation(),
383
+ new Promise((_, reject) => {
384
+ timer = setTimeout(() => {
385
+ const message = `${method} timed out after ${timeoutMs}ms`;
386
+ reject(new Error(message));
387
+ client.fail(message);
388
+ }, timeoutMs);
389
+ }),
390
+ ]);
391
+ }
392
+ finally {
393
+ clearTimeout(timer);
394
+ }
395
+ }
396
+ stopClient(client) {
397
+ client.stopping = true;
398
+ if (this.clients.get(client.key) === client)
399
+ this.clients.delete(client.key);
400
+ if (this.initializing.get(client.key) === client)
401
+ this.initializing.delete(client.key);
402
+ // JSON-RPC close alone does not reject pending requests; dispose does.
403
+ client.connection.dispose();
404
+ if (client.process.exitCode === null && client.process.signalCode === null) {
405
+ client.process.kill();
406
+ const forceKill = setTimeout(() => {
407
+ if (client.process.exitCode === null && client.process.signalCode === null)
408
+ client.process.kill("SIGKILL");
409
+ }, 1_000);
410
+ forceKill.unref();
411
+ client.process.once("exit", () => clearTimeout(forceKill));
412
+ }
413
+ for (const file of client.documents.keys()) {
414
+ this.diagnosticsByFile.get(file)?.delete(client.id);
415
+ this.resolveDiagnosticWaiters(file);
416
+ }
309
417
  }
310
418
  async openOrChange(client, file) {
311
419
  const text = await readFile(file, "utf-8");
@@ -314,13 +422,13 @@ export class ProjectLspService {
314
422
  const existing = client.documents.get(file);
315
423
  if (!existing) {
316
424
  client.documents.set(file, { languageId, version: 1 });
317
- client.connection.sendNotification("textDocument/didOpen", {
425
+ await this.notify(client, "textDocument/didOpen", {
318
426
  textDocument: { uri, languageId, version: 1, text },
319
427
  });
320
428
  return;
321
429
  }
322
430
  existing.version += 1;
323
- client.connection.sendNotification("textDocument/didChange", {
431
+ await this.notify(client, "textDocument/didChange", {
324
432
  textDocument: { uri, version: existing.version },
325
433
  contentChanges: [{ text }],
326
434
  });
@@ -392,29 +500,41 @@ export class ProjectLspService {
392
500
  resolveInsideCwd(filePath) {
393
501
  const file = normalize(isAbsolute(filePath) ? filePath : resolve(this.cwd, filePath));
394
502
  const rel = relative(this.cwd, file);
395
- if (rel.startsWith("..") || isAbsolute(rel))
503
+ // Only a whole `..` segment climbs out; `..notes.md` is a file in the project.
504
+ if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel))
396
505
  return undefined;
397
506
  return file;
398
507
  }
399
508
  async shutdownClient(client) {
400
509
  client.stopping = true;
401
- await client.connection.sendRequest("shutdown").catch(() => undefined);
402
- client.connection.sendNotification("exit");
403
- client.connection.dispose();
404
- client.process.kill();
510
+ try {
511
+ await this.request(client, "shutdown", undefined, this.timeouts.shutdownMs ?? 1_000);
512
+ await this.withDeadline(client, "exit", () => client.connection.sendNotification("exit"), this.timeouts.shutdownMs ?? 1_000);
513
+ }
514
+ catch {
515
+ // Shutdown must finish even if the server no longer answers.
516
+ }
517
+ finally {
518
+ this.stopClient(client);
519
+ }
405
520
  }
406
521
  async shutdownClients() {
522
+ this.generation += 1;
523
+ for (const client of [...this.initializing.values()])
524
+ this.stopClient(client);
525
+ this.spawning.clear();
407
526
  await Promise.all([...this.clients.values()].map((client) => this.shutdownClient(client)));
408
527
  this.clients.clear();
409
528
  this.starting.clear();
410
529
  this.diagnosticsByFile.clear();
411
530
  }
412
531
  async shutdownDisabledClients() {
532
+ for (const client of [...this.initializing.values()]) {
533
+ if (!isLspServerEnabled(this.config, client.id))
534
+ this.stopClient(client);
535
+ }
413
536
  const disabled = [...this.clients.values()].filter((client) => !isLspServerEnabled(this.config, client.id));
414
537
  await Promise.all(disabled.map((client) => this.shutdownClient(client)));
415
- for (const client of disabled) {
416
- this.clients.delete(client.key);
417
- }
418
538
  }
419
539
  }
420
540
  export const TypeScriptLspService = ProjectLspService;
@@ -446,7 +566,7 @@ const TypeScriptServer = {
446
566
  if (!tsserverPath || !serverPath)
447
567
  return undefined;
448
568
  return {
449
- process: spawn(process.execPath, [serverPath, "--stdio"], { cwd: root, env: process.env, stdio: "pipe" }),
569
+ process: spawnNodeServer([serverPath, "--stdio"], root),
450
570
  initializationOptions: { tsserver: { path: tsserverPath } },
451
571
  };
452
572
  },
@@ -485,7 +605,7 @@ const VueServer = {
485
605
  return undefined;
486
606
  const args = tsdk ? [serverPath, "--stdio", "--tsdk", tsdk] : [serverPath, "--stdio"];
487
607
  return {
488
- process: spawn(process.execPath, args, { cwd: root, env: process.env, stdio: "pipe" }),
608
+ process: spawnNodeServer(args, root),
489
609
  };
490
610
  },
491
611
  };
@@ -506,7 +626,7 @@ const ESLintServer = {
506
626
  if (!serverPath)
507
627
  return undefined;
508
628
  return {
509
- process: spawn(process.execPath, [serverPath, "--stdio"], { cwd: root, env: process.env, stdio: "pipe" }),
629
+ process: spawnNodeServer([serverPath, "--stdio"], root),
510
630
  };
511
631
  },
512
632
  configuration: (root, items) => items.map(() => eslintConfiguration(root)),
package/dist/main.js CHANGED
@@ -5,17 +5,20 @@
5
5
  import chalk from "chalk";
6
6
  import { Agent } from "./agent.js";
7
7
  import { BudgetLedger } from "./agent/budget-ledger.js";
8
- import { parseArgs, printHelp } from "./cli.js";
8
+ import { enterWorkingDirectory, parseArgs, printHelp } from "./cli.js";
9
9
  import { effectiveThemeModeForTerminal, shouldProbeTerminalTheme, UserConfig } from "./config.js";
10
10
  import { createProviderInstance, createUnavailableProvider } from "./provider.js";
11
11
  import { resolveConfiguredModel } from "./model-selection.js";
12
12
  import { getAvailableThinkingLevels, getDefaultThinkingLevel, normalizeThinkingLevel } from "./provider-transform.js";
13
13
  import { ProviderRegistry, displayModel, encodeModel, decodeModel } from "./provider-registry.js";
14
14
  import { SessionManager } from "./session.js";
15
+ import { SessionContextFence } from "./session-context-fence.js";
15
16
  import { createSessionTitleUpdater } from "./session-title.js";
16
17
  import { buildSystemPrompt } from "./system-prompt.js";
17
18
  import { createRoutableModelIndex, createRoutingSnapshotAccessor } from "./agent/routing-catalog.js";
18
19
  import { buildModelRoutingPrompt } from "./prompt/routing.js";
20
+ /** Upper bound on blocking startup for OAuth catalog discovery (one HTTP round trip normally). */
21
+ const MODEL_DISCOVERY_STARTUP_WAIT_MS = 3_000;
19
22
  import { SkillRegistry } from "./skills/registry.js";
20
23
  import { buildToolPromptOptions, createAllTools } from "./tools/index.js";
21
24
  import { getProcessManager } from "./tasks/manager.js";
@@ -30,7 +33,8 @@ import { SettingsManager } from "./permissions/settings.js";
30
33
  import { ExternalHookController } from "./hooks/index.js";
31
34
  import { getLspService } from "./lsp/index.js";
32
35
  import { loadMcpConfig } from "./mcp/config.js";
33
- import { McpManager } from "./mcp/manager.js";
36
+ import { promptRepoTrust } from "./permissions/trust-prompt.js";
37
+ import { McpManager, gateMcpTools } from "./mcp/manager.js";
34
38
  import { normalizeInheritedThinkingLevel } from "./variant/variant-resolver.js";
35
39
  import { QuestionController } from "./question/index.js";
36
40
  import { buildMemoryPrompt, formatMemoryStartupResult, purgeUnsafeMemorySources, recordMemoryCitations, runMemoryPhase2, runMemoryStartupPipeline, startMemoryStartupTask, } from "./memory/index.js";
@@ -52,6 +56,13 @@ async function main() {
52
56
  console.log(`v${getCurrentVersion()}`);
53
57
  process.exit(0);
54
58
  }
59
+ // --cwd means "run as if launched there" (like git -C): every layer, including
60
+ // the TUI, shell children and relative paths, then sees the same directory.
61
+ const cwdError = enterWorkingDirectory(args.cwd);
62
+ if (cwdError) {
63
+ console.error(`bubble: ${cwdError}`);
64
+ process.exit(1);
65
+ }
55
66
  if (args.command === "update") {
56
67
  const { runUpdateCommand } = await import("./update/index.js");
57
68
  const code = await runUpdateCommand({ checkOnly: args.checkOnly });
@@ -86,10 +97,10 @@ async function main() {
86
97
  console.error(chalk.red("Error: No provider configured. Start interactive mode and use /login or /provider --add <id>."));
87
98
  process.exit(1);
88
99
  }
89
- console.log(chalk.dim("No provider configured yet. Start with /login for ChatGPT or /provider --add <id> for an API key."));
100
+ console.log(chalk.dim("No provider configured yet. Start with /login for ChatGPT or Grok, or /provider --add <id> for an API key."));
90
101
  }
91
102
  const defaultProvider = registry.getDefault();
92
- const unavailableProviderMessage = "No provider configured. Use /login for ChatGPT or /provider --add <id> before sending a prompt.";
103
+ const unavailableProviderMessage = "No provider configured. Use /login for ChatGPT or Grok, or /provider --add <id> before sending a prompt.";
93
104
  let sessionPromptCacheKey;
94
105
  const provider = defaultProvider
95
106
  ? createProviderInstance({
@@ -149,6 +160,11 @@ async function main() {
149
160
  const approvalHandlerRef = {};
150
161
  const questionController = new QuestionController();
151
162
  const bashAllowlist = new BashAllowlist();
163
+ // Folder trust (Kimi Code style): the repository's .bubble settings may
164
+ // enable allow rules, MCP servers and LSP servers; ask before loading them.
165
+ if (!printMode && process.stdin.isTTY && process.stdout.isTTY) {
166
+ await promptRepoTrust(args.cwd);
167
+ }
152
168
  const settingsManager = new SettingsManager(args.cwd);
153
169
  for (const d of settingsManager.getMerged().diagnostics) {
154
170
  console.error(chalk.yellow(`[settings:${d.scope}] ${d.path}: ${d.message}`));
@@ -161,6 +177,7 @@ async function main() {
161
177
  getMode: () => agentRef?.mode ?? "default",
162
178
  handlerRef: approvalHandlerRef,
163
179
  bashAllowlist,
180
+ sessionGrants: new Set(),
164
181
  cwd: args.cwd,
165
182
  getRuleSet: () => settingsManager.getMerged().ruleSet,
166
183
  externalHooks: hookController,
@@ -209,7 +226,7 @@ async function main() {
209
226
  let externalRuntime;
210
227
  if (mcpLoaded.servers.length > 0) {
211
228
  await mcpManager.start();
212
- tools.push(...mcpManager.getToolEntries());
229
+ tools.push(...gateMcpTools(mcpManager.getToolEntries(), approvalController));
213
230
  }
214
231
  // Expose MCP prompts as slash commands. Queried live at each lookup so
215
232
  // /mcp reconnect picks up new prompts without restarting the process.
@@ -320,8 +337,21 @@ async function main() {
320
337
  ? decodeModel(normalizedConfiguredModel)
321
338
  : { providerId: undefined, modelId: "" };
322
339
  let activeProviderId = effectiveProviderId || fallbackProviderId;
323
- if (registry.supportsOAuth(activeProviderId) && registry.getAuthStorage().has(activeProviderId)) {
324
- await registry.prepareProvider(activeProviderId);
340
+ // getConfigured() resolves the legacy openai-codex auth alias; the raw
341
+ // auth key would skip token refresh and discovery for such logins.
342
+ if (registry.getConfigured().find((item) => item.id === activeProviderId)?.authType === "oauth") {
343
+ try {
344
+ await registry.prepareProvider(activeProviderId);
345
+ }
346
+ catch (error) {
347
+ // A dead login must not lock the user out: /login only exists inside the
348
+ // session, so start anyway and let the first request surface the error.
349
+ console.error(chalk.yellow(`[auth] ${error instanceof Error ? error.message : String(error)}`));
350
+ }
351
+ // The routing prompt below is composed once; give the account catalog a
352
+ // bounded chance to land first so the menu the model reads matches what a
353
+ // spawn will actually get (design §1.5).
354
+ await registry.waitForModelDiscovery(activeProviderId, MODEL_DISCOVERY_STARTUP_WAIT_MS);
325
355
  }
326
356
  const activeProvider = registry.getConfigured().find((p) => p.id === activeProviderId) || defaultProvider;
327
357
  const activeModel = activeProvider && effectiveModelId
@@ -356,6 +386,8 @@ async function main() {
356
386
  : undefined;
357
387
  const systemPrompt = buildSystemPrompt({
358
388
  agentName: "Bubble",
389
+ skills: skillSummaries,
390
+ skillCatalogChars: userConfig.getSkillCatalogChars(),
359
391
  configuredProvider: activeProviderId || "none",
360
392
  configuredModel: activeModel ? displayModel(activeModel) : "none",
361
393
  configuredModelId: activeModel || "none",
@@ -389,6 +421,30 @@ async function main() {
389
421
  }
390
422
  const budgetLedger = new BudgetLedger();
391
423
  let sessionTitleUpdater;
424
+ let contextFence = new SessionContextFence(sessionManager);
425
+ // A fence rejection means the durable log diverged from this host's resident
426
+ // history (a foreign append/clear/rewind landed while a turn was in flight).
427
+ // The write is refused — and the resident transcript must roll back to the
428
+ // file's truth, or the rejected message lingers in the TUI while every later
429
+ // fenced write keeps failing on the stale revision until a manual reload.
430
+ const persistFenced = (write) => {
431
+ try {
432
+ write();
433
+ }
434
+ catch (error) {
435
+ // Any refused write (fence rejection, busy lock, I/O) leaves the agent's
436
+ // already-pushed message unpersisted, so always restore the file's truth.
437
+ try {
438
+ const history = contextFence.reloadHistory();
439
+ const head = splitLeadingContext(agent.messages).leading;
440
+ agent.messages = [...head, ...history];
441
+ }
442
+ catch {
443
+ // Surface the original write failure; the next write retries the reload.
444
+ }
445
+ throw error;
446
+ }
447
+ };
392
448
  const agent = new Agent({
393
449
  provider: activeProvider
394
450
  ? createProvider(activeProviderId, activeProvider.apiKey, activeProvider.baseURL)
@@ -410,7 +466,8 @@ async function main() {
410
466
  // they will be re-injected as needed on resume based on the current mode.
411
467
  if (message.role === "meta")
412
468
  return;
413
- sessionManager.appendMessage(message);
469
+ const manager = sessionManager;
470
+ persistFenced(() => manager.appendMessage(message, contextFence.getRevision()));
414
471
  traceEvent("session_message_persisted", {
415
472
  message: summarizeTraceMessage(message),
416
473
  });
@@ -420,12 +477,14 @@ async function main() {
420
477
  }
421
478
  },
422
479
  onProviderError: (error) => {
423
- sessionManager?.appendProviderError(error);
480
+ persistFenced(() => sessionManager?.appendProviderError(error, contextFence.getRevision()));
424
481
  },
425
- // Auto-compaction summaries are meta messages (dropped above); persist the
426
- // compacted state as a session summary entry so it survives resume.
427
- onCompactionApplied: (summary) => {
428
- sessionManager?.applyLLMCompaction(summary);
482
+ getContextRevision: () => contextFence.getRevision(),
483
+ onContextCheckpoint: (checkpoint) => {
484
+ if (!sessionManager)
485
+ throw new Error("No session available for context commit");
486
+ const manager = sessionManager;
487
+ persistFenced(() => manager.commitContextCheckpoint(checkpoint, contextFence.getRevision()));
429
488
  },
430
489
  onToolResult: (toolName, result) => {
431
490
  if (!sessionManager)
@@ -434,14 +493,16 @@ async function main() {
434
493
  return;
435
494
  const match = result.content.match(/^Skill:\s+([^\n]+)$/m);
436
495
  if (match?.[1]) {
437
- sessionManager.appendMarker("skill_activated", match[1].trim());
496
+ const manager = sessionManager;
497
+ persistFenced(() => manager.appendMarker("skill_activated", match[1].trim(), contextFence.getRevision()));
438
498
  }
439
499
  },
440
500
  onModeUpdate: (mode) => {
441
- sessionManager?.appendMarker("mode_switch", mode);
501
+ persistFenced(() => sessionManager?.appendMarker("mode_switch", mode, contextFence.getRevision()));
442
502
  },
443
503
  budgetLedger,
444
504
  skills: skillSummaries,
505
+ skillCatalogChars: userConfig.getSkillCatalogChars(),
445
506
  memoryPrompt,
446
507
  fileStateTracker,
447
508
  agentCategories: userConfig.getAgentCategories(),
@@ -550,7 +611,7 @@ async function main() {
550
611
  }
551
612
  // Restore session if requested
552
613
  if (resumedExistingSession && sessionManager) {
553
- const history = sessionManager.getMessages();
614
+ const history = contextFence.reloadHistory();
554
615
  if (history.length > 0) {
555
616
  agent.messages = [{ role: "system", content: systemPrompt }, ...history];
556
617
  // Reassigning agent.messages drops any runtime meta reminder injected during
@@ -699,6 +760,7 @@ async function main() {
699
760
  const activateSession = (next) => {
700
761
  try {
701
762
  const history = next.getMessages();
763
+ const historyRevision = next.getRevision();
702
764
  const nextPromptCacheKey = next.getOrCreatePromptCacheKey();
703
765
  const nextTitleUpdater = createSessionTitleUpdater({
704
766
  sessionManager: next,
@@ -717,6 +779,10 @@ async function main() {
717
779
  // Commit only after every file read/write and reconstruction step has
718
780
  // succeeded. Callers can safely prepare candidate sessions without a
719
781
  // failed switch rebinding persistence or replacing the live history.
782
+ if (next.getRevision() !== historyRevision)
783
+ throw new Error("Session changed while switching; retry");
784
+ contextFence.dispose();
785
+ contextFence = new SessionContextFence(next);
720
786
  sessionManager = next;
721
787
  sessionPromptCacheKey = nextPromptCacheKey;
722
788
  sessionTitleUpdater = nextTitleUpdater;
@@ -5,6 +5,10 @@
5
5
  * identified by its key in mcpServers; if the same name appears in multiple
6
6
  * scopes, the higher-precedence scope overwrites, and we record a diagnostic.
7
7
  *
8
+ * Servers from the project and local files arrive with the repository and
9
+ * Bubble would spawn their commands at startup, so they are skipped until the
10
+ * user trusts the folder (asked at startup, trust.ts).
11
+ *
8
12
  * Env expansion: ${VAR} in command, args, env values, url, or header values
9
13
  * is replaced with the matching process.env entry. Missing vars become empty
10
14
  * strings and yield a diagnostic (non-fatal).
@@ -5,6 +5,10 @@
5
5
  * identified by its key in mcpServers; if the same name appears in multiple
6
6
  * scopes, the higher-precedence scope overwrites, and we record a diagnostic.
7
7
  *
8
+ * Servers from the project and local files arrive with the repository and
9
+ * Bubble would spawn their commands at startup, so they are skipped until the
10
+ * user trusts the folder (asked at startup, trust.ts).
11
+ *
8
12
  * Env expansion: ${VAR} in command, args, env values, url, or header values
9
13
  * is replaced with the matching process.env entry. Missing vars become empty
10
14
  * strings and yield a diagnostic (non-fatal).
@@ -12,6 +16,7 @@
12
16
  import { existsSync, readFileSync } from "node:fs";
13
17
  import { join } from "node:path";
14
18
  import { getBubbleHome } from "../bubble-home.js";
19
+ import { isRepoConfigTrusted, readRepoSettings } from "../permissions/trust.js";
15
20
  export function loadMcpConfig(options) {
16
21
  const bubbleHome = options.bubbleHome ?? getBubbleHome();
17
22
  const paths = {
@@ -21,6 +26,7 @@ export function loadMcpConfig(options) {
21
26
  };
22
27
  const diagnostics = [];
23
28
  const merged = new Map();
29
+ const repoTrusted = isRepoConfigTrusted(options.cwd, readRepoSettings(options.cwd), { bubbleHome });
24
30
  for (const scope of ["user", "project", "local"]) {
25
31
  const path = paths[scope];
26
32
  if (!existsSync(path))
@@ -38,6 +44,17 @@ export function loadMcpConfig(options) {
38
44
  const mcpServers = raw.mcpServers;
39
45
  if (!mcpServers || typeof mcpServers !== "object" || Array.isArray(mcpServers))
40
46
  continue;
47
+ if (scope !== "user" && !repoTrusted) {
48
+ const names = Object.keys(mcpServers);
49
+ if (names.length > 0) {
50
+ diagnostics.push({
51
+ scope,
52
+ path,
53
+ message: `Skipped MCP server${names.length === 1 ? "" : "s"} ${names.join(", ")} from this repository: the folder is not trusted (Bubble asks at startup).`,
54
+ });
55
+ }
56
+ continue;
57
+ }
41
58
  for (const [name, value] of Object.entries(mcpServers)) {
42
59
  const validated = validateServerConfig(value, (msg) => {
43
60
  diagnostics.push({ scope, path, message: `mcpServers.${name}: ${msg}` });