@bubblebrain-ai/bubble 0.0.54 → 0.0.56
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.
- package/README.md +43 -0
- package/dist/agent/input-controller.d.ts +3 -0
- package/dist/agent/input-controller.js +13 -0
- package/dist/agent.js +7 -1
- package/dist/context/budget.js +7 -2
- package/dist/model-catalog.d.ts +1 -1
- package/dist/model-catalog.js +12 -2
- package/dist/model-pricing.d.ts +1 -5
- package/dist/model-pricing.js +0 -0
- package/dist/provider-ai-sdk.js +5 -0
- package/dist/provider-ark-responses.d.ts +12 -2
- package/dist/provider-ark-responses.js +68 -13
- package/dist/provider-registry.js +34 -10
- package/dist/provider.js +7 -1
- package/dist/sdk/index.d.ts +97 -6
- package/dist/sdk/index.js +445 -34
- package/dist/sdk/replay-event-log.d.ts +19 -0
- package/dist/sdk/replay-event-log.js +72 -0
- package/dist/sdk/session-turn-coordinator.d.ts +45 -0
- package/dist/sdk/session-turn-coordinator.js +227 -0
- package/dist/tui/app.js +5 -3
- package/dist/tui/controller/controller.js +12 -6
- package/dist/tui/fullscreen.js +2 -1
- package/dist/tui/model/image-paste.js +9 -10
- package/dist/types.d.ts +14 -2
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
this
|
|
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
|
-
|
|
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
|
|
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 (
|
|
301
|
+
if (abortSignal.aborted)
|
|
127
302
|
decide();
|
|
128
303
|
else
|
|
129
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
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, {
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
268
|
-
|
|
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
|
-
|
|
273
|
-
|
|
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
|
-
|
|
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
|
+
}
|