@bubblebrain-ai/bubble 0.0.53 → 0.0.55

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 (34) hide show
  1. package/README.md +43 -0
  2. package/dist/agent/input-controller.d.ts +3 -0
  3. package/dist/agent/input-controller.js +13 -0
  4. package/dist/agent.js +7 -1
  5. package/dist/model-catalog.js +7 -2
  6. package/dist/model-pricing.d.ts +1 -5
  7. package/dist/model-pricing.js +0 -0
  8. package/dist/provider-ai-sdk.js +5 -0
  9. package/dist/provider-registry.js +34 -10
  10. package/dist/sdk/index.d.ts +97 -6
  11. package/dist/sdk/index.js +445 -34
  12. package/dist/sdk/replay-event-log.d.ts +19 -0
  13. package/dist/sdk/replay-event-log.js +72 -0
  14. package/dist/sdk/session-turn-coordinator.d.ts +45 -0
  15. package/dist/sdk/session-turn-coordinator.js +227 -0
  16. package/dist/tui/app.js +5 -3
  17. package/dist/tui/controller/controller.js +12 -6
  18. package/dist/tui/fullscreen.js +2 -1
  19. package/dist/types.d.ts +11 -1
  20. package/dist/update/index.d.ts +1 -0
  21. package/dist/update/index.js +22 -17
  22. package/package.json +5 -3
  23. /package/{packages/pi-tui/node_modules → node_modules}/marked/LICENSE +0 -0
  24. /package/{packages/pi-tui/node_modules → node_modules}/marked/README.md +0 -0
  25. /package/{packages/pi-tui/node_modules → node_modules}/marked/bin/main.js +0 -0
  26. /package/{packages/pi-tui/node_modules → node_modules}/marked/bin/marked.js +0 -0
  27. /package/{packages/pi-tui/node_modules → node_modules}/marked/lib/marked.d.ts +0 -0
  28. /package/{packages/pi-tui/node_modules → node_modules}/marked/lib/marked.esm.js +0 -0
  29. /package/{packages/pi-tui/node_modules → node_modules}/marked/lib/marked.esm.js.map +0 -0
  30. /package/{packages/pi-tui/node_modules → node_modules}/marked/lib/marked.umd.js +0 -0
  31. /package/{packages/pi-tui/node_modules → node_modules}/marked/lib/marked.umd.js.map +0 -0
  32. /package/{packages/pi-tui/node_modules → node_modules}/marked/man/marked.1 +0 -0
  33. /package/{packages/pi-tui/node_modules → node_modules}/marked/man/marked.1.md +0 -0
  34. /package/{packages/pi-tui/node_modules → node_modules}/marked/package.json +0 -0
package/dist/sdk/index.js CHANGED
@@ -16,7 +16,10 @@
16
16
  import os from "node:os";
17
17
  import { rmSync } from "node:fs";
18
18
  import { randomUUID } from "node:crypto";
19
- import { Agent } from "../agent.js";
19
+ import { Agent, AgentAbortError } from "../agent.js";
20
+ import { AgentRunInputQueue } from "../agent/input-controller.js";
21
+ import { SessionTurnCoordinator, } from "./session-turn-coordinator.js";
22
+ import { ReplayEventLog } from "./replay-event-log.js";
20
23
  import { SessionManager } from "../session.js";
21
24
  import { PermissionAwareApprovalController } from "../approval/controller.js";
22
25
  import { BashAllowlist } from "../approval/session-cache.js";
@@ -48,8 +51,13 @@ export class BubbleSdk {
48
51
  mcpEnabled;
49
52
  cwdBySession = new Map();
50
53
  bashAllowlists = new Map();
51
- activeTurns = new Map();
54
+ turnCoordinator = new SessionTurnCoordinator();
55
+ turnRuntimes = new Map();
56
+ lastTurnOptions = new Map();
52
57
  mcpToolsByCwd = new Map();
58
+ inputIdNonce = randomUUID().slice(0, 6);
59
+ nextTurnInputPrefix = 0;
60
+ nextDetachedInputId = 0;
53
61
  /** id -> {cwd,file} rebuilt from disk so sessions survive host restarts. */
54
62
  sessionIndex = new Map();
55
63
  constructor(options = {}) {
@@ -58,31 +66,129 @@ export class BubbleSdk {
58
66
  }
59
67
  // ── Sessions ─────────────────────────────────────────────────────────────
60
68
  listSessions() {
61
- const sessions = SessionManager.listAllSessions();
69
+ const sessions = SessionManager.listAllSessions().filter((session) => !this.turnCoordinator.isDeleted(session.name));
62
70
  this.sessionIndex = new Map(sessions.map((s) => [s.name, { cwd: s.cwd ?? s.cwdLabel ?? this.defaultCwd, file: s.file }]));
63
71
  return sessions;
64
72
  }
65
73
  createSession(options = {}) {
66
74
  const cwd = options.cwd || this.defaultCwd;
67
75
  const id = options.id || `sdk-${Date.now().toString(36)}-${randomUUID().slice(0, 8)}`;
76
+ this.turnCoordinator.revive(id);
68
77
  SessionManager.create(cwd, sessionFileName(id));
78
+ processSessionLocations.set(id, cwd);
69
79
  this.cwdBySession.set(id, cwd);
70
80
  return { id, cwd };
71
81
  }
82
+ openSession(sessionId) {
83
+ const resolved = this.resolveSession(sessionId);
84
+ if (!resolved)
85
+ throw new Error(`Unknown session: ${sessionId}`);
86
+ const ownerKey = resolved.manager.getSessionFile();
87
+ const eventLog = sessionEventLogFor(ownerKey);
88
+ const closed = new AbortController();
89
+ return {
90
+ id: sessionId,
91
+ cwd: resolved.cwd,
92
+ events: eventLog.iterate({ signal: closed.signal }),
93
+ eventsFrom: (afterSequence) => eventLog.iterate({
94
+ from: Math.max(0, afterSequence),
95
+ signal: closed.signal,
96
+ }),
97
+ send: (options) => this.runTurn(sessionId, options),
98
+ steer: (content) => this.steer(sessionId, content),
99
+ stop: (options) => this.stop(sessionId, options),
100
+ close: () => closed.abort(),
101
+ };
102
+ }
72
103
  getHistory(sessionId) {
104
+ if (this.turnCoordinator.isDeleted(sessionId))
105
+ return [];
73
106
  return this.resolveSession(sessionId)?.manager.getMessages() ?? [];
74
107
  }
75
- deleteSession(sessionId) {
108
+ async deleteSession(sessionId) {
109
+ const owner = this.ownerForSession(sessionId);
110
+ if (owner && owner !== this)
111
+ return owner.deleteSession(sessionId);
76
112
  const resolved = this.resolveSession(sessionId);
113
+ for (const runtime of this.runtimesForSession(sessionId))
114
+ runtime.inputController.closePendingInputs();
115
+ await this.turnCoordinator.delete(sessionId);
77
116
  if (resolved)
78
117
  rmSync(resolved.manager.getSessionFile(), { force: true });
79
118
  this.cwdBySession.delete(sessionId);
80
119
  this.bashAllowlists.delete(sessionId);
120
+ this.lastTurnOptions.delete(sessionId);
81
121
  this.sessionIndex.delete(sessionId);
122
+ processSessionLocations.delete(sessionId);
123
+ if (resolved) {
124
+ releaseSessionEventLog(resolved.manager.getSessionFile());
125
+ releaseProcessOwner(resolved.manager.getSessionFile(), this);
126
+ }
127
+ }
128
+ /** Interrupt the active turn. Queued turns survive unless cancelQueued is true. */
129
+ stop(sessionId, options = {}) {
130
+ const owner = this.ownerForSession(sessionId);
131
+ if (owner && owner !== this)
132
+ return owner.stop(sessionId, options);
133
+ const current = this.turnCoordinator.getCurrent(sessionId);
134
+ const runtime = current ? this.turnRuntimes.get(current.id) : undefined;
135
+ runtime?.inputController.closePendingInputs();
136
+ const stopped = this.turnCoordinator.stopCurrent(sessionId);
137
+ return stopped + (options.cancelQueued ? this.turnCoordinator.clearQueue(sessionId) : 0);
138
+ }
139
+ /** Cancel queued turns without interrupting the turn that owns the session slot. */
140
+ clearQueue(sessionId) {
141
+ const owner = this.ownerForSession(sessionId);
142
+ if (owner && owner !== this)
143
+ return owner.clearQueue(sessionId);
144
+ return this.turnCoordinator.clearQueue(sessionId);
82
145
  }
83
- /** Abort the in-flight turn of a session, if any. */
84
- stop(sessionId) {
85
- this.activeTurns.get(sessionId)?.abort();
146
+ getSessionRunState(sessionId) {
147
+ const owner = this.ownerForSession(sessionId);
148
+ if (owner && owner !== this)
149
+ return owner.getSessionRunState(sessionId);
150
+ const state = this.turnCoordinator.getState(sessionId);
151
+ const current = this.turnCoordinator.getCurrent(sessionId);
152
+ const runtime = current ? this.turnRuntimes.get(current.id) : undefined;
153
+ return {
154
+ active: state.active,
155
+ queuedTurns: state.queued,
156
+ pendingSteers: runtime?.outcomes.size ?? 0,
157
+ phase: state.phase,
158
+ };
159
+ }
160
+ /**
161
+ * Add text to the turn that currently owns the session slot. Every accepted
162
+ * input exposes an outcome promise, independent of event-stream consumption.
163
+ */
164
+ steer(sessionId, content) {
165
+ if (this.turnCoordinator.isDeleted(sessionId)) {
166
+ return { accepted: false, disposition: "rejected", reason: "session_deleted" };
167
+ }
168
+ const owner = this.ownerForSession(sessionId);
169
+ if (owner && owner !== this)
170
+ return owner.steer(sessionId, content);
171
+ const resolved = this.resolveSession(sessionId);
172
+ if (!resolved) {
173
+ return { accepted: false, disposition: "rejected", reason: "unknown_session" };
174
+ }
175
+ const current = this.turnCoordinator.getCurrent(sessionId);
176
+ const runtime = current ? this.turnRuntimes.get(current.id) : undefined;
177
+ if (runtime) {
178
+ const input = runtime.inputController.tryEnqueue(content);
179
+ if (input) {
180
+ const pending = pendingSteerOutcome(input);
181
+ runtime.outcomes.set(input.id, pending);
182
+ return { accepted: true, disposition: "steered", input, outcome: pending.promise };
183
+ }
184
+ }
185
+ return this.queueDetachedInput(sessionId, content, resolved);
186
+ }
187
+ enqueueTurn(sessionId, options) {
188
+ return this.runTurn(sessionId, options);
189
+ }
190
+ queueTurn(sessionId, options) {
191
+ return this.enqueueTurn(sessionId, options);
86
192
  }
87
193
  // ── Discovery (composer pickers) ─────────────────────────────────────────
88
194
  listSkills(cwd) {
@@ -103,30 +209,99 @@ export class BubbleSdk {
103
209
  };
104
210
  }
105
211
  // ── The turn ─────────────────────────────────────────────────────────────
106
- async *runTurn(sessionId, options) {
212
+ /**
213
+ * Reserve and start a session turn immediately. Execution is driven by the
214
+ * SDK's own pump, not by consumption of the returned iterator: the iterator
215
+ * is a replay subscription, so a host may drop it (or reconnect later via
216
+ * `openSession`) without stalling or cancelling the turn. Cancel with
217
+ * `stop()`, the options signal, or `deleteSession()`.
218
+ */
219
+ runTurn(sessionId, options) {
107
220
  const resolved = this.resolveSession(sessionId);
108
221
  if (!resolved)
109
222
  throw new Error(`Unknown session: ${sessionId}`);
223
+ const ownerKey = resolved.manager.getSessionFile();
224
+ const owner = processSessionOwners.get(ownerKey);
225
+ if (owner && owner !== this)
226
+ return owner.runTurn(sessionId, options);
227
+ claimProcessOwner(ownerKey, this);
228
+ try {
229
+ const runtime = this.startOwnedTurn(sessionId, options, resolved, ownerKey);
230
+ return runtime.events.iterate();
231
+ }
232
+ catch (error) {
233
+ // A synchronous reserve() failure (e.g. pre-aborted signal) must not
234
+ // leave this SDK pinned as process owner of a session it never ran.
235
+ if (!this.turnCoordinator.getState(sessionId).active) {
236
+ releaseProcessOwner(ownerKey, this);
237
+ }
238
+ throw error;
239
+ }
240
+ }
241
+ startOwnedTurn(sessionId, options, resolved, ownerKey) {
242
+ const reservation = this.turnCoordinator.reserve(sessionId, options.signal);
243
+ const runtime = {
244
+ sessionId,
245
+ ownerKey,
246
+ reservation,
247
+ inputController: new AgentRunInputQueue(`sdk-turn-${this.inputIdNonce}-${++this.nextTurnInputPrefix}`),
248
+ outcomes: new Map(),
249
+ options,
250
+ events: new ReplayEventLog(),
251
+ };
252
+ this.lastTurnOptions.set(sessionId, inheritableTurnOptions(options));
253
+ this.turnRuntimes.set(reservation.id, runtime);
254
+ void this.pumpTurn(runtime, resolved);
255
+ return runtime;
256
+ }
257
+ async pumpTurn(runtime, resolved) {
258
+ let failure;
259
+ try {
260
+ for await (const event of this.runReservedTurn(runtime, runtime.options, resolved)) {
261
+ runtime.events.append(event);
262
+ this.publishSessionEvent(runtime, event);
263
+ }
264
+ }
265
+ catch (error) {
266
+ failure = error;
267
+ }
268
+ finally {
269
+ if (failure !== undefined) {
270
+ // The session-level log never closes per turn, so replay subscribers
271
+ // need an explicit terminal record — otherwise a failed turn is
272
+ // indistinguishable from one that is still running.
273
+ this.publishSessionEvent(runtime, { type: "turn_end", willContinue: false }, {
274
+ kind: failure instanceof AgentAbortError || runtime.reservation.signal.aborted
275
+ ? "cancelled"
276
+ : "failed",
277
+ message: failure instanceof Error ? failure.message : String(failure),
278
+ });
279
+ }
280
+ runtime.events.close(failure);
281
+ if (this.turnRuntimes.get(runtime.reservation.id) === runtime) {
282
+ this.turnRuntimes.delete(runtime.reservation.id);
283
+ }
284
+ if (!this.turnCoordinator.getState(runtime.sessionId).active) {
285
+ releaseProcessOwner(runtime.ownerKey, this);
286
+ }
287
+ }
288
+ }
289
+ async *runReservedTurn(runtime, options, resolved) {
290
+ const { sessionId, reservation, inputController } = runtime;
110
291
  const { manager: session, cwd } = resolved;
111
292
  const mode = options.mode ?? "default";
112
- const abort = new AbortController();
113
- if (options.signal) {
114
- if (options.signal.aborted)
115
- abort.abort();
116
- else
117
- options.signal.addEventListener("abort", () => abort.abort(), { once: true });
118
- }
119
- this.activeTurns.set(sessionId, abort);
293
+ const abortSignal = reservation.signal;
120
294
  let agentRef;
295
+ let streamCompleted = false;
121
296
  const hookController = new ExternalHookController({ cwd, sessionId });
122
297
  // Settles as reject the moment the turn aborts, so a tool blocked on a
123
298
  // host approval (or question) can never hang the abort path.
124
299
  const abortedDecision = new Promise((resolve) => {
125
300
  const decide = () => resolve({ action: "reject", feedback: "Turn aborted" });
126
- if (abort.signal.aborted)
301
+ if (abortSignal.aborted)
127
302
  decide();
128
303
  else
129
- abort.signal.addEventListener("abort", decide, { once: true });
304
+ abortSignal.addEventListener("abort", decide, { once: true });
130
305
  });
131
306
  const approvalController = new PermissionAwareApprovalController({
132
307
  getMode: () => agentRef?.mode ?? mode,
@@ -146,7 +321,9 @@ export class BubbleSdk {
146
321
  const planController = {
147
322
  getMode: () => agentRef?.mode ?? mode,
148
323
  requestApproval: async (plan) => {
149
- const approved = options.onPlanApproval ? await options.onPlanApproval(plan) : false;
324
+ const approved = options.onPlanApproval
325
+ ? await awaitWithAbort(options.onPlanApproval(plan), abortSignal)
326
+ : false;
150
327
  if (approved) {
151
328
  agentRef?.setMode("default");
152
329
  return { action: "approve", plan };
@@ -166,8 +343,9 @@ export class BubbleSdk {
166
343
  }
167
344
  options.onQuestion(request).then((answers) => answers ? questionController.reply(request.id, answers) : questionController.reject(request.id), () => questionController.reject(request.id));
168
345
  });
169
- abort.signal.addEventListener("abort", () => questionController.rejectAll(), { once: true });
346
+ abortSignal.addEventListener("abort", () => questionController.rejectAll(), { once: true });
170
347
  try {
348
+ await reservation.waitForStart();
171
349
  const skillRegistry = new SkillRegistry({
172
350
  cwd,
173
351
  skillPaths: this.userConfig.getSkillPaths(),
@@ -181,9 +359,12 @@ export class BubbleSdk {
181
359
  goalStore: new GoalStore(),
182
360
  checkpoints: () => session.getCheckpoints(),
183
361
  });
184
- tools.push(...(await this.mcpToolsFor(cwd)));
362
+ tools.push(...(await awaitWithAbort(this.mcpToolsFor(cwd), abortSignal)));
363
+ throwAbortSignal(abortSignal);
185
364
  const promptCacheKey = session.getOrCreatePromptCacheKey();
186
- session.updateMetadata({ cwd }); // recoverable by cwd after host restart
365
+ if (!this.turnCoordinator.isDeleted(sessionId)) {
366
+ session.updateMetadata({ cwd }); // recoverable by cwd after host restart
367
+ }
187
368
  const { provider, providerId, model } = this.resolveProvider(promptCacheKey, options.model);
188
369
  const thinkingLevel = options.thinkingLevel ??
189
370
  this.userConfig.getDefaultThinkingLevel() ??
@@ -230,20 +411,28 @@ export class BubbleSdk {
230
411
  onMessageAppend: (message) => {
231
412
  if (message.role === "system" || message.role === "meta")
232
413
  return;
414
+ if (this.turnCoordinator.isDeleted(sessionId))
415
+ return;
233
416
  session.appendMessage(message);
234
417
  if (message.role === "assistant")
235
418
  recordMemoryCitations(cwd, message.content);
236
419
  },
237
420
  onCompactionApplied: (summary) => {
421
+ if (this.turnCoordinator.isDeleted(sessionId))
422
+ return;
238
423
  session.applyLLMCompaction(summary);
239
424
  },
240
- onModeUpdate: (m) => session.appendMarker("mode_switch", m),
425
+ onModeUpdate: (m) => {
426
+ if (!this.turnCoordinator.isDeleted(sessionId))
427
+ session.appendMarker("mode_switch", m);
428
+ },
241
429
  });
242
430
  agentRef = agent;
243
431
  const history = session.getMessages();
244
432
  if (history.length > 0) {
245
433
  agent.messages = [{ role: "system", content: systemPrompt }, ...history];
246
434
  }
435
+ reservation.markActive();
247
436
  options.onStart?.({
248
437
  providerId,
249
438
  model,
@@ -257,23 +446,146 @@ export class BubbleSdk {
257
446
  // recognizing the slash syntax on its own.
258
447
  const prompt = rewriteSkillInvocationPrompt(options.prompt, skillRegistry);
259
448
  const bareModelId = decodeModel(model).modelId;
260
- for await (const event of agent.run(prompt, cwd, { abortSignal: abort.signal })) {
261
- yield attachTurnCost(event, providerId, bareModelId);
262
- if (abort.signal.aborted)
263
- break;
449
+ for await (const event of agent.run(prompt, cwd, {
450
+ abortSignal,
451
+ inputController,
452
+ })) {
453
+ const sdkEvent = this.handleSteerEvent(runtime, event, resolved);
454
+ yield attachTurnCost(sdkEvent, providerId, bareModelId);
264
455
  }
456
+ streamCompleted = true;
457
+ for (const event of this.rejectOutstandingSteers(runtime, "no_continuation", resolved))
458
+ yield event;
459
+ }
460
+ catch (error) {
461
+ const reason = abortSignal.aborted ? "turn_cancelled" : "turn_failed";
462
+ for (const event of this.rejectOutstandingSteers(runtime, reason))
463
+ yield event;
464
+ throw error;
265
465
  }
266
466
  finally {
267
- // Also reached via generator.return() when the host stops consuming
268
- // early: aborting here unblocks any tool still awaiting an approval.
269
- abort.abort();
467
+ inputController.closePendingInputs();
468
+ this.rejectOutstandingSteers(runtime, streamCompleted ? "no_continuation" : abortSignal.aborted ? "turn_cancelled" : "turn_failed", streamCompleted ? resolved : undefined);
270
469
  unsubscribeQuestions();
271
470
  questionController.rejectAll();
272
- if (this.activeTurns.get(sessionId) === abort)
273
- this.activeTurns.delete(sessionId);
471
+ reservation.finish();
472
+ if (this.turnRuntimes.get(reservation.id) === runtime)
473
+ this.turnRuntimes.delete(reservation.id);
274
474
  }
275
475
  }
276
476
  // ── Internals ────────────────────────────────────────────────────────────
477
+ runtimesForSession(sessionId) {
478
+ return [...this.turnRuntimes.values()].filter((runtime) => runtime.sessionId === sessionId);
479
+ }
480
+ settleSteerFromEvent(runtime, event) {
481
+ if (event.type !== "input_applied" && event.type !== "input_rejected")
482
+ return;
483
+ const pending = runtime.outcomes.get(event.id);
484
+ if (!pending)
485
+ return;
486
+ runtime.outcomes.delete(event.id);
487
+ pending.resolve(event);
488
+ }
489
+ handleSteerEvent(runtime, event, resolved) {
490
+ if (event.type === "input_rejected" && event.reason === "no_continuation") {
491
+ const pending = runtime.outcomes.get(event.id);
492
+ if (pending)
493
+ return this.queuePendingSteer(runtime, pending, resolved, event);
494
+ }
495
+ this.settleSteerFromEvent(runtime, event);
496
+ return event;
497
+ }
498
+ rejectOutstandingSteers(runtime, reason, resolved) {
499
+ runtime.inputController.closePendingInputs();
500
+ const events = [];
501
+ for (const [id, pending] of runtime.outcomes) {
502
+ const event = {
503
+ type: "input_rejected",
504
+ id,
505
+ content: pending.input.content,
506
+ reason,
507
+ target: "next_turn",
508
+ };
509
+ runtime.outcomes.delete(id);
510
+ if (reason === "no_continuation" && resolved) {
511
+ events.push(this.queuePendingSteer(runtime, pending, resolved, event));
512
+ }
513
+ else {
514
+ pending.resolve(event);
515
+ events.push(event);
516
+ }
517
+ }
518
+ return events;
519
+ }
520
+ queuePendingSteer(runtime, pending, resolved, rejected) {
521
+ try {
522
+ const queuedRuntime = this.startOwnedTurn(runtime.sessionId, { ...inheritableTurnOptions(runtime.options), prompt: pending.input.content }, resolved, runtime.ownerKey);
523
+ const queued = {
524
+ type: "input_queued",
525
+ id: pending.input.id,
526
+ content: pending.input.content,
527
+ turnId: queuedRuntime.reservation.id,
528
+ target: "next_turn",
529
+ };
530
+ // The follow-up turn's own subscribers also see the queue marker; the
531
+ // session-level log already gets it once via this turn's yielded event.
532
+ queuedRuntime.events.append(queued);
533
+ runtime.outcomes.delete(pending.input.id);
534
+ pending.resolve(queued);
535
+ return queued;
536
+ }
537
+ catch {
538
+ runtime.outcomes.delete(pending.input.id);
539
+ pending.resolve(rejected);
540
+ return rejected;
541
+ }
542
+ }
543
+ queueDetachedInput(sessionId, content, resolved) {
544
+ const ownerKey = resolved.manager.getSessionFile();
545
+ try {
546
+ claimProcessOwner(ownerKey, this);
547
+ const input = {
548
+ id: `sdk-steer-${this.inputIdNonce}-${++this.nextDetachedInputId}`,
549
+ content,
550
+ submittedAt: Date.now(),
551
+ };
552
+ const runtime = this.startOwnedTurn(sessionId, { ...(this.lastTurnOptions.get(sessionId) ?? {}), prompt: content }, resolved, ownerKey);
553
+ const queued = {
554
+ type: "input_queued",
555
+ id: input.id,
556
+ content,
557
+ turnId: runtime.reservation.id,
558
+ target: "next_turn",
559
+ };
560
+ runtime.events.append(queued);
561
+ this.publishSessionEvent(runtime, queued);
562
+ return {
563
+ accepted: true,
564
+ disposition: "queued",
565
+ input,
566
+ turnId: runtime.reservation.id,
567
+ outcome: Promise.resolve(queued),
568
+ };
569
+ }
570
+ catch {
571
+ releaseProcessOwner(ownerKey, this);
572
+ return { accepted: false, disposition: "rejected", reason: "ownership_conflict" };
573
+ }
574
+ }
575
+ publishSessionEvent(runtime, event, terminal) {
576
+ const log = sessionEventLogFor(runtime.ownerKey);
577
+ log.append({
578
+ sequence: log.length + 1,
579
+ sessionId: runtime.sessionId,
580
+ turnId: runtime.reservation.id,
581
+ event,
582
+ ...(terminal ? { terminal } : {}),
583
+ });
584
+ }
585
+ ownerForSession(sessionId) {
586
+ const resolved = this.resolveSession(sessionId);
587
+ return resolved ? processSessionOwners.get(resolved.manager.getSessionFile()) : undefined;
588
+ }
277
589
  bashAllowlistFor(sessionId) {
278
590
  let allowlist = this.bashAllowlists.get(sessionId);
279
591
  if (!allowlist) {
@@ -332,6 +644,8 @@ export class BubbleSdk {
332
644
  return cached;
333
645
  }
334
646
  resolveSession(sessionId) {
647
+ if (this.turnCoordinator.isDeleted(sessionId))
648
+ return undefined;
335
649
  const memCwd = this.cwdBySession.get(sessionId);
336
650
  if (memCwd) {
337
651
  // create() only resolves the path and loads iff the file exists, so this
@@ -341,8 +655,20 @@ export class BubbleSdk {
341
655
  if (this.sessionIndex.size === 0)
342
656
  this.listSessions();
343
657
  const entry = this.sessionIndex.get(sessionId);
344
- if (!entry)
345
- return undefined;
658
+ if (!entry) {
659
+ // A session created in this process but not yet persisted to disk is
660
+ // still resolvable (and routable) for other SDK instances.
661
+ const registeredCwd = processSessionLocations.get(sessionId);
662
+ if (!registeredCwd)
663
+ return undefined;
664
+ this.cwdBySession.set(sessionId, registeredCwd);
665
+ return {
666
+ manager: SessionManager.create(registeredCwd, sessionFileName(sessionId)),
667
+ cwd: registeredCwd,
668
+ };
669
+ }
670
+ // The disk entry supersedes the pre-persistence registry entry.
671
+ processSessionLocations.delete(sessionId);
346
672
  this.cwdBySession.set(sessionId, entry.cwd);
347
673
  return { manager: new SessionManager(entry.file), cwd: entry.cwd };
348
674
  }
@@ -379,9 +705,93 @@ export class BubbleSdk {
379
705
  return { provider, providerId: activeProviderId, model: activeModel };
380
706
  }
381
707
  }
708
+ /**
709
+ * Process-local ownership routes every producer to one SDK runner. Hosts that
710
+ * run multiple OS processes still need a broker or distributed per-session
711
+ * lease; two processes cannot safely share a local JSONL transcript directly.
712
+ */
713
+ const processSessionOwners = new Map();
714
+ const sessionEventLogs = new Map();
715
+ /** sessionId -> cwd for sessions created in this process but not yet on disk. */
716
+ const processSessionLocations = new Map();
717
+ function claimProcessOwner(ownerKey, sdk) {
718
+ const existing = processSessionOwners.get(ownerKey);
719
+ if (existing && existing !== sdk) {
720
+ throw new Error(`SDK session is owned by another runner: ${ownerKey}`);
721
+ }
722
+ processSessionOwners.set(ownerKey, sdk);
723
+ }
724
+ function releaseProcessOwner(ownerKey, sdk) {
725
+ if (processSessionOwners.get(ownerKey) === sdk)
726
+ processSessionOwners.delete(ownerKey);
727
+ }
728
+ function sessionEventLogFor(ownerKey) {
729
+ let log = sessionEventLogs.get(ownerKey);
730
+ if (!log) {
731
+ log = new ReplayEventLog();
732
+ sessionEventLogs.set(ownerKey, log);
733
+ }
734
+ return log;
735
+ }
736
+ /** End live iterators and drop the buffered events of a deleted session. */
737
+ function releaseSessionEventLog(ownerKey) {
738
+ const log = sessionEventLogs.get(ownerKey);
739
+ if (!log)
740
+ return;
741
+ log.close();
742
+ sessionEventLogs.delete(ownerKey);
743
+ }
744
+ function inheritableTurnOptions(options) {
745
+ const { prompt: _prompt, signal: _signal, ...inherited } = options;
746
+ return inherited;
747
+ }
382
748
  function sessionFileName(sessionId) {
383
749
  return sessionId.endsWith(".jsonl") ? sessionId : `${sessionId}.jsonl`;
384
750
  }
751
+ function pendingSteerOutcome(input) {
752
+ let resolve;
753
+ const promise = new Promise((done) => {
754
+ resolve = done;
755
+ });
756
+ return { input, promise, resolve };
757
+ }
758
+ function throwAbortSignal(signal) {
759
+ if (!signal.aborted)
760
+ return;
761
+ if (signal.reason instanceof Error)
762
+ throw signal.reason;
763
+ throw new AgentAbortError(typeof signal.reason === "string" ? signal.reason : "SDK turn cancelled.");
764
+ }
765
+ function awaitWithAbort(promise, signal) {
766
+ if (signal.aborted) {
767
+ try {
768
+ throwAbortSignal(signal);
769
+ }
770
+ catch (error) {
771
+ return Promise.reject(error);
772
+ }
773
+ }
774
+ return new Promise((resolve, reject) => {
775
+ const onAbort = () => {
776
+ cleanup();
777
+ try {
778
+ throwAbortSignal(signal);
779
+ }
780
+ catch (error) {
781
+ reject(error);
782
+ }
783
+ };
784
+ const cleanup = () => signal.removeEventListener("abort", onAbort);
785
+ signal.addEventListener("abort", onAbort, { once: true });
786
+ promise.then((value) => {
787
+ cleanup();
788
+ resolve(value);
789
+ }, (error) => {
790
+ cleanup();
791
+ reject(error);
792
+ });
793
+ });
794
+ }
385
795
  /**
386
796
  * Attach a `cost` field to turn_end events that carry usage, priced via the
387
797
  * static pricing table. Pricing is an accounting concern, so it rides along
@@ -417,6 +827,7 @@ export function rewriteSkillInvocationPrompt(prompt, registry) {
417
827
  }
418
828
  // ── Building blocks (escape hatch for custom hosts) ────────────────────────
419
829
  export { Agent, AgentAbortError } from "../agent.js";
830
+ export { AgentRunInputQueue } from "../agent/input-controller.js";
420
831
  export { SessionManager, getSessionsDir, } from "../session.js";
421
832
  export { PermissionAwareApprovalController } from "../approval/controller.js";
422
833
  export { BashAllowlist } from "../approval/session-cache.js";
@@ -0,0 +1,19 @@
1
+ /**
2
+ * A small append-only event log for SDK streams. Producers never wait for a
3
+ * consumer, and consumers can attach again with their last seen sequence.
4
+ */
5
+ export declare class ReplayEventLog<T> {
6
+ private readonly entries;
7
+ private signal;
8
+ private closed;
9
+ private failure;
10
+ get length(): number;
11
+ append(value: T): void;
12
+ close(error?: unknown): void;
13
+ iterate(options?: {
14
+ from?: number;
15
+ signal?: AbortSignal;
16
+ }): AsyncGenerator<T>;
17
+ private readFrom;
18
+ private wake;
19
+ }