@bubblebrain-ai/bubble 0.0.59 → 0.0.61
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/dist/agent/subagent/runtime.d.ts +28 -0
- package/dist/agent/subagent/runtime.js +4 -24
- package/dist/agent.d.ts +7 -6
- package/dist/agent.js +18 -1
- package/dist/cli.d.ts +5 -0
- package/dist/cli.js +25 -2
- package/dist/config.d.ts +3 -0
- package/dist/config.js +7 -0
- package/dist/context/compact.js +6 -2
- package/dist/context/tool-images.d.ts +3 -0
- package/dist/context/tool-images.js +35 -0
- package/dist/context/usage.js +22 -5
- package/dist/feishu/agent-host/run-driver.js +3 -0
- package/dist/lsp/service.js +3 -2
- package/dist/main.js +11 -1
- package/dist/model-catalog.js +6 -0
- package/dist/model-pricing.js +17 -0
- package/dist/permissions/shell-command.js +35 -3
- package/dist/prompt/compose.d.ts +3 -0
- package/dist/prompt/compose.js +17 -3
- package/dist/provider-ai-sdk.d.ts +2 -1
- package/dist/provider-ai-sdk.js +26 -9
- package/dist/provider-openai-codex.d.ts +4 -0
- package/dist/provider-openai-codex.js +28 -9
- package/dist/provider-registry.js +4 -2
- package/dist/sdk/index.d.ts +21 -2
- package/dist/sdk/index.js +92 -11
- package/dist/session.d.ts +5 -1
- package/dist/session.js +13 -5
- package/dist/skills/format.d.ts +31 -1
- package/dist/skills/format.js +45 -24
- package/dist/slash-commands/commands.d.ts +3 -0
- package/dist/slash-commands/commands.js +6 -4
- package/dist/system-prompt.d.ts +3 -1
- package/dist/tools/edit-apply.d.ts +0 -3
- package/dist/tools/edit-apply.js +45 -249
- package/dist/tools/edit.js +3 -3
- package/dist/tools/grep.js +20 -1
- package/dist/tools/skill.d.ts +2 -0
- package/dist/tools/skill.js +34 -2
- package/dist/tui/app.js +6 -0
- package/dist/tui/components/skills-panel.d.ts +4 -0
- package/dist/tui/components/skills-panel.js +15 -1
- package/dist/tui/components/tasks-pane.d.ts +38 -0
- package/dist/tui/components/tasks-pane.js +182 -32
- package/dist/tui/controller/controller.d.ts +7 -0
- package/dist/tui/controller/controller.js +13 -0
- package/dist/types.d.ts +7 -0
- package/package.json +1 -1
- package/dist/prompt/skills.d.ts +0 -2
- package/dist/prompt/skills.js +0 -4
package/dist/provider-ai-sdk.js
CHANGED
|
@@ -137,17 +137,23 @@ function buildCallOptions(messages, chatOptions, providerOptions) {
|
|
|
137
137
|
};
|
|
138
138
|
}
|
|
139
139
|
/**
|
|
140
|
-
* Gemini 3 models take a graded thinking_level; 2.5-era models take a token
|
|
140
|
+
* Gemini 3+ models take a graded thinking_level; 2.5-era models take a token
|
|
141
141
|
* budget. "off" (budget 0) is only offered in the catalog for models that
|
|
142
142
|
* accept it (2.5 Flash); xhigh/max/ultra clamp to high.
|
|
143
143
|
*/
|
|
144
144
|
function buildGoogleThinkingOptions(modelId, level) {
|
|
145
145
|
if (!level)
|
|
146
146
|
return undefined;
|
|
147
|
+
const version = geminiVersion(modelId);
|
|
148
|
+
// Gemini 4 accepts only low/medium/high: MINIMAL is a 400 and thinkingBudget
|
|
149
|
+
// is undocumented. Side calls (titles, compaction) request "off" by default,
|
|
150
|
+
// so floor those at low rather than sending a value the API rejects.
|
|
151
|
+
if (version >= 4 && (level === "off" || level === "minimal"))
|
|
152
|
+
level = "low";
|
|
147
153
|
if (level === "off")
|
|
148
154
|
return { thinkingConfig: { thinkingBudget: 0 } };
|
|
149
155
|
const clamped = level === "xhigh" || level === "max" || level === "ultra" ? "high" : level;
|
|
150
|
-
if (
|
|
156
|
+
if (version >= 3) {
|
|
151
157
|
return { thinkingConfig: { thinkingLevel: clamped, includeThoughts: true } };
|
|
152
158
|
}
|
|
153
159
|
const budgets = { minimal: 512, low: 2048, medium: 8192, high: 24576 };
|
|
@@ -459,10 +465,13 @@ export async function fetchGeminiModels(options) {
|
|
|
459
465
|
}
|
|
460
466
|
/**
|
|
461
467
|
* Ranking: one entry per (version, tier) family — GA ids beat dated previews —
|
|
462
|
-
* then newest version first, pro before flash before
|
|
468
|
+
* then newest version first, flagship (pro/argon) before flash before
|
|
469
|
+
* flash-lite, top N.
|
|
463
470
|
*/
|
|
464
471
|
export function selectLatestGeminiModels(entries, limit = 5) {
|
|
465
|
-
|
|
472
|
+
// Gemini 4 introduced named flagship tiers (gemini-4-argon). Unknown names
|
|
473
|
+
// stay excluded rather than guessing whether they are text models.
|
|
474
|
+
const TIER_RANK = { argon: 0, pro: 0, flash: 1, "flash-lite": 2 };
|
|
466
475
|
const candidates = [];
|
|
467
476
|
for (const entry of entries) {
|
|
468
477
|
const id = (entry.name ?? "").replace(/^models\//, "");
|
|
@@ -472,21 +481,23 @@ export function selectLatestGeminiModels(entries, limit = 5) {
|
|
|
472
481
|
continue;
|
|
473
482
|
if (entry.supportedGenerationMethods && !entry.supportedGenerationMethods.includes("generateContent"))
|
|
474
483
|
continue;
|
|
475
|
-
const match = /^gemini-(\d+(?:\.\d+)?)-(pro|flash)(-lite)?/.exec(id);
|
|
484
|
+
const match = /^gemini-(\d+(?:\.\d+)?)-(argon|pro|flash)(-lite)?/.exec(id);
|
|
476
485
|
if (!match)
|
|
477
486
|
continue;
|
|
487
|
+
const tier = `${match[2]}${match[3] ?? ""}`;
|
|
478
488
|
candidates.push({
|
|
479
489
|
id,
|
|
480
490
|
entry,
|
|
481
491
|
version: Number.parseFloat(match[1]),
|
|
482
|
-
|
|
492
|
+
tier,
|
|
493
|
+
tierRank: TIER_RANK[tier] ?? 3,
|
|
483
494
|
isPreview: id.includes("preview") || id.includes("exp"),
|
|
484
495
|
});
|
|
485
496
|
}
|
|
486
497
|
// One winner per family: GA over preview, then the shortest (least-suffixed) id.
|
|
487
498
|
const families = new Map();
|
|
488
499
|
for (const candidate of candidates) {
|
|
489
|
-
const key = `${candidate.version}-${candidate.
|
|
500
|
+
const key = `${candidate.version}-${candidate.tier}`;
|
|
490
501
|
const current = families.get(key);
|
|
491
502
|
if (!current
|
|
492
503
|
|| (current.isPreview && !candidate.isPreview)
|
|
@@ -502,13 +513,19 @@ export function selectLatestGeminiModels(entries, limit = 5) {
|
|
|
502
513
|
name: candidate.entry.displayName || candidate.id,
|
|
503
514
|
contextWindow: Number.isFinite(candidate.entry.inputTokenLimit) ? candidate.entry.inputTokenLimit : undefined,
|
|
504
515
|
reasoningLevels: geminiReasoningLevels(candidate.id),
|
|
505
|
-
|
|
516
|
+
// Argon defaults to Google's recommended medium via the catalog.
|
|
517
|
+
...(candidate.tier === "pro" ? { defaultReasoningLevel: "high" } : {}),
|
|
506
518
|
}));
|
|
507
519
|
}
|
|
520
|
+
function geminiVersion(modelId) {
|
|
521
|
+
return Number.parseFloat(/^gemini-(\d+(?:\.\d+)?)/.exec(modelId)?.[1] ?? "0");
|
|
522
|
+
}
|
|
508
523
|
/** Mirrors the static catalog's per-family thinking support. */
|
|
509
524
|
export function geminiReasoningLevels(modelId) {
|
|
510
|
-
const version =
|
|
525
|
+
const version = geminiVersion(modelId);
|
|
511
526
|
const isPro = modelId.includes("-pro");
|
|
527
|
+
if (version >= 4)
|
|
528
|
+
return ["low", "medium", "high"];
|
|
512
529
|
// Live probes show 3.7 and 3.8 Flash share this newer ladder. Keep the
|
|
513
530
|
// exception bounded to verified releases instead of guessing for 3.9+.
|
|
514
531
|
if (!isPro && (version === 3.7 || version === 3.8)) {
|
|
@@ -47,6 +47,8 @@ export declare function fetchOpenAICodexModels(options: {
|
|
|
47
47
|
export interface OpenAICodexModelCatalogResult {
|
|
48
48
|
descriptors: CodexModelDescriptor[];
|
|
49
49
|
status: "success" | "unavailable";
|
|
50
|
+
/** Why each discovery path failed, when status is "unavailable". */
|
|
51
|
+
reason?: string;
|
|
50
52
|
}
|
|
51
53
|
/**
|
|
52
54
|
* Fetches the account-scoped Codex catalog while preserving the distinction
|
|
@@ -58,5 +60,7 @@ export declare function fetchOpenAICodexModelCatalog(options: {
|
|
|
58
60
|
fetch?: ChatGptFetch;
|
|
59
61
|
/** Bounds both catalog GETs; a stalled warm-up must not pin the in-flight slot. */
|
|
60
62
|
signal?: AbortSignal;
|
|
63
|
+
/** Per-request timeout, so a slow first path still leaves the fallback a full window. */
|
|
64
|
+
timeoutMs?: number;
|
|
61
65
|
}): Promise<OpenAICodexModelCatalogResult>;
|
|
62
66
|
export declare function sortCodexModelDescriptors(descriptors: CodexModelDescriptor[]): CodexModelDescriptor[];
|
|
@@ -12,8 +12,9 @@ const TOKEN_REFRESH_GRACE_MS = 5 * 60 * 1000;
|
|
|
12
12
|
// OpenAI gates new codex models server-side by client_version (each model carries a
|
|
13
13
|
// `minimal_client_version`). Track a recent real Codex CLI release; override via env
|
|
14
14
|
// when OpenAI lifts the gate again before we cut a new release.
|
|
15
|
-
// 2026-09-17: gpt-6-astra requires 0.153.0
|
|
16
|
-
|
|
15
|
+
// 2026-09-17: gpt-6-astra requires 0.153.0.
|
|
16
|
+
// 2026-10-06: gpt-6.1-sol requires 0.159.0, the version the Codex app sends.
|
|
17
|
+
const CODEX_CLIENT_VERSION = process.env.BUBBLE_CODEX_CLIENT_VERSION?.trim() || "0.159.0";
|
|
17
18
|
/** The Codex client version Bubble claims; the account catalog the server returns depends on it. */
|
|
18
19
|
export function getCodexClientVersion() {
|
|
19
20
|
return CODEX_CLIENT_VERSION;
|
|
@@ -346,32 +347,50 @@ export async function fetchOpenAICodexModels(options) {
|
|
|
346
347
|
export async function fetchOpenAICodexModelCatalog(options) {
|
|
347
348
|
const accountId = extractChatGptAccountId(options.accessToken);
|
|
348
349
|
if (!accountId) {
|
|
349
|
-
return { descriptors: [], status: "unavailable" };
|
|
350
|
+
return { descriptors: [], status: "unavailable", reason: "access token has no ChatGPT account id" };
|
|
350
351
|
}
|
|
351
352
|
const fetchImpl = options.fetch ?? chatGptFetch;
|
|
353
|
+
const failures = [];
|
|
352
354
|
for (const path of MODEL_DISCOVERY_PATHS) {
|
|
355
|
+
const signal = options.timeoutMs === undefined
|
|
356
|
+
? options.signal
|
|
357
|
+
: options.signal
|
|
358
|
+
? AbortSignal.any([options.signal, AbortSignal.timeout(options.timeoutMs)])
|
|
359
|
+
: AbortSignal.timeout(options.timeoutMs);
|
|
360
|
+
const startedAt = Date.now();
|
|
353
361
|
const response = await fetchImpl(resolveRelativeUrl(options.baseURL, path), {
|
|
354
362
|
method: "GET",
|
|
355
363
|
headers: buildBaseHeaders(options.accessToken, accountId, globalThis.crypto?.randomUUID?.() ?? `bubble_${Date.now()}`, { accept: "application/json" }),
|
|
356
|
-
signal
|
|
357
|
-
}).catch(() =>
|
|
358
|
-
|
|
364
|
+
signal,
|
|
365
|
+
}).catch((error) => {
|
|
366
|
+
failures.push(`${path}: ${error instanceof Error ? error.message : String(error)} after ${Date.now() - startedAt}ms`);
|
|
367
|
+
return undefined;
|
|
368
|
+
});
|
|
369
|
+
if (!response)
|
|
370
|
+
continue;
|
|
371
|
+
if (!response.ok) {
|
|
372
|
+
failures.push(`${path}: HTTP ${response.status} after ${Date.now() - startedAt}ms`);
|
|
359
373
|
continue;
|
|
374
|
+
}
|
|
360
375
|
const parsed = await response.json()
|
|
361
376
|
.then((payload) => ({ ok: true, payload }))
|
|
362
377
|
.catch(() => ({ ok: false }));
|
|
363
|
-
if (!parsed.ok)
|
|
378
|
+
if (!parsed.ok) {
|
|
379
|
+
failures.push(`${path}: invalid JSON`);
|
|
364
380
|
continue;
|
|
381
|
+
}
|
|
365
382
|
const payload = parsed.payload;
|
|
366
383
|
if (!Array.isArray(payload) && !(payload && typeof payload === "object"
|
|
367
|
-
&& (Array.isArray(payload.models) || Array.isArray(payload.data))))
|
|
384
|
+
&& (Array.isArray(payload.models) || Array.isArray(payload.data)))) {
|
|
385
|
+
failures.push(`${path}: unexpected payload`);
|
|
368
386
|
continue;
|
|
387
|
+
}
|
|
369
388
|
return {
|
|
370
389
|
descriptors: sortCodexModelDescriptors(extractCodexModelDescriptors(payload)),
|
|
371
390
|
status: "success",
|
|
372
391
|
};
|
|
373
392
|
}
|
|
374
|
-
return { descriptors: [], status: "unavailable" };
|
|
393
|
+
return { descriptors: [], status: "unavailable", reason: failures.join("; ") };
|
|
375
394
|
}
|
|
376
395
|
function buildRequestBody(messages, options) {
|
|
377
396
|
const instructions = messages
|
|
@@ -74,6 +74,8 @@ const MODEL_DISCOVERY_FAILURE_TTL_MS = 10_000;
|
|
|
74
74
|
const MODEL_DISCOVERY_DISK_TTL_MS = 24 * 60 * 60 * 1000;
|
|
75
75
|
/** Discovery must never delay startup or a model picker for long. */
|
|
76
76
|
const MODEL_DISCOVERY_TIMEOUT_MS = 5_000;
|
|
77
|
+
// Per request: the ChatGPT backend is often reached through a user proxy.
|
|
78
|
+
const OPENAI_CODEX_MODEL_DISCOVERY_TIMEOUT_MS = 15_000;
|
|
77
79
|
// Included in the cache identity so a pre-single-model OpenRouter disk cache
|
|
78
80
|
// can never repopulate the picker with the old full remote catalog.
|
|
79
81
|
const OPENROUTER_CATALOG_SCOPE = "ox-alpha-only-v2";
|
|
@@ -821,10 +823,10 @@ export class ProviderRegistry {
|
|
|
821
823
|
const catalog = await fetchOpenAICodexModelCatalog({
|
|
822
824
|
baseURL: currentProvider.baseURL,
|
|
823
825
|
accessToken: currentProvider.apiKey,
|
|
824
|
-
|
|
826
|
+
timeoutMs: OPENAI_CODEX_MODEL_DISCOVERY_TIMEOUT_MS,
|
|
825
827
|
});
|
|
826
828
|
if (catalog.status === "unavailable") {
|
|
827
|
-
throw new Error(
|
|
829
|
+
throw new Error(`OpenAI Codex model catalog is unavailable${catalog.reason ? ` (${catalog.reason})` : ""}.`);
|
|
828
830
|
}
|
|
829
831
|
const visible = catalog.descriptors.filter((descriptor) => descriptor.visibility !== "hide");
|
|
830
832
|
return {
|
package/dist/sdk/index.d.ts
CHANGED
|
@@ -22,7 +22,7 @@ import type { ResolvedSubagentRoute } from "../agent/categories.js";
|
|
|
22
22
|
import { type QuestionAnswer, type QuestionRequest } from "../question/controller.js";
|
|
23
23
|
import { SkillRegistry } from "../skills/registry.js";
|
|
24
24
|
import type { SkillSummary } from "../skills/types.js";
|
|
25
|
-
import type { AgentEvent, AgentRunInput, ContentPart, Message, PermissionMode, Provider, ThinkingLevel } from "../types.js";
|
|
25
|
+
import type { AgentEvent, AgentRunInput, ContentPart, Message, PermissionMode, Provider, ThinkingLevel, ToolRegistryEntry } from "../types.js";
|
|
26
26
|
export interface BubbleSdkOptions {
|
|
27
27
|
/** Optional process-local replay window. Active readers never lose unread
|
|
28
28
|
* events. Older cursors fail explicitly; restore durable session history.
|
|
@@ -72,8 +72,22 @@ export interface TurnHandlers {
|
|
|
72
72
|
onStart?: (info: TurnStartInfo) => void;
|
|
73
73
|
}
|
|
74
74
|
export interface RunTurnOptions extends TurnHandlers {
|
|
75
|
+
/**
|
|
76
|
+
* Turn-scoped host capabilities. Never cached globally or given to subagents;
|
|
77
|
+
* follow-up turns the SDK starts for this turn (queued input, pending steers)
|
|
78
|
+
* reuse this turn's options, these included.
|
|
79
|
+
*/
|
|
80
|
+
hostTools?: ToolRegistryEntry[];
|
|
81
|
+
/**
|
|
82
|
+
* Extra skill directories for this turn, e.g. skills a host app ships with.
|
|
83
|
+
* Searched after the user's own roots, so a user skill of the same name wins.
|
|
84
|
+
* Like hostTools, follow-up turns the SDK starts for this turn reuse them.
|
|
85
|
+
*/
|
|
86
|
+
skillPaths?: string[];
|
|
75
87
|
/** Plain text, or content parts (text + base64 images) for attachments. */
|
|
76
88
|
prompt: string | ContentPart[];
|
|
89
|
+
/** Preserve an admitted input if cancelled before initialization records it. */
|
|
90
|
+
preserveInterruptedInput?: boolean;
|
|
77
91
|
/** "provider:model" or bare model id (resolved against the default provider). */
|
|
78
92
|
model?: string;
|
|
79
93
|
mode?: PermissionMode;
|
|
@@ -192,9 +206,14 @@ export declare class BubbleSdk {
|
|
|
192
206
|
}): SdkSessionRef;
|
|
193
207
|
openSession(sessionId: string): SdkSessionHandle;
|
|
194
208
|
getHistory(sessionId: string): Message[];
|
|
209
|
+
/** Preserve an accepted desktop input cancelled before runTurn was admitted. */
|
|
210
|
+
recordInterruptedInput(sessionId: string, prompt: string | ContentPart[]): void;
|
|
211
|
+
private persistInterruptedInput;
|
|
195
212
|
deleteSession(sessionId: string): Promise<void>;
|
|
196
213
|
/** Interrupt the active turn. Queued turns survive unless cancelQueued is true. */
|
|
197
214
|
stop(sessionId: string, options?: SdkStopOptions): number;
|
|
215
|
+
/** Stop and wait for persistence, cleanup, and terminal event publication. */
|
|
216
|
+
stopAndWait(sessionId: string, options?: SdkStopOptions): Promise<number>;
|
|
198
217
|
/** Cancel queued turns without interrupting the turn that owns the session slot. */
|
|
199
218
|
clearQueue(sessionId: string): number;
|
|
200
219
|
getSessionRunState(sessionId: string): SdkSessionRunState;
|
|
@@ -205,7 +224,7 @@ export declare class BubbleSdk {
|
|
|
205
224
|
steer(sessionId: string, content: string): SdkSteerResult;
|
|
206
225
|
enqueueTurn(sessionId: string, options: RunTurnOptions): AsyncGenerator<AgentEvent>;
|
|
207
226
|
queueTurn(sessionId: string, options: RunTurnOptions): AsyncGenerator<AgentEvent>;
|
|
208
|
-
listSkills(cwd?: string): SkillSummary[];
|
|
227
|
+
listSkills(cwd?: string, skillPaths?: string[]): SkillSummary[];
|
|
209
228
|
/**
|
|
210
229
|
* Ask the host once per folder (onProjectTrust) before this turn loads the
|
|
211
230
|
* repository's allow rules, MCP servers and LSP servers. Trusting restarts
|
package/dist/sdk/index.js
CHANGED
|
@@ -118,6 +118,31 @@ export class BubbleSdk {
|
|
|
118
118
|
return [];
|
|
119
119
|
return this.resolveSession(sessionId)?.manager.getMessages() ?? [];
|
|
120
120
|
}
|
|
121
|
+
/** Preserve an accepted desktop input cancelled before runTurn was admitted. */
|
|
122
|
+
recordInterruptedInput(sessionId, prompt) {
|
|
123
|
+
const owner = this.ownerForSession(sessionId);
|
|
124
|
+
if (owner && owner !== this)
|
|
125
|
+
return owner.recordInterruptedInput(sessionId, prompt);
|
|
126
|
+
if (this.turnCoordinator.isDeleted(sessionId) || this.getSessionRunState(sessionId).active) {
|
|
127
|
+
throw new Error(`Cannot record interrupted input while session is active or deleted: ${sessionId}`);
|
|
128
|
+
}
|
|
129
|
+
const resolved = this.resolveSession(sessionId);
|
|
130
|
+
if (!resolved)
|
|
131
|
+
throw new Error(`Unknown session: ${sessionId}`);
|
|
132
|
+
this.persistInterruptedInput(resolved, prompt);
|
|
133
|
+
}
|
|
134
|
+
persistInterruptedInput(resolved, prompt) {
|
|
135
|
+
const session = resolved.manager;
|
|
136
|
+
session.getMessages();
|
|
137
|
+
const revision = session.getRevision();
|
|
138
|
+
session.updateMetadata({ cwd: resolved.cwd });
|
|
139
|
+
session.appendMessage({ role: "user", content: prompt }, revision);
|
|
140
|
+
session.appendMessage({
|
|
141
|
+
role: "assistant",
|
|
142
|
+
content: "[Interrupted by user before execution started.]",
|
|
143
|
+
error: { name: "MessageAbortedError", message: "Turn stopped during initialization.", aborted: true },
|
|
144
|
+
}, session.getRevision());
|
|
145
|
+
}
|
|
121
146
|
async deleteSession(sessionId) {
|
|
122
147
|
const owner = this.ownerForSession(sessionId);
|
|
123
148
|
if (owner && owner !== this)
|
|
@@ -147,8 +172,21 @@ export class BubbleSdk {
|
|
|
147
172
|
const current = this.turnCoordinator.getCurrent(sessionId);
|
|
148
173
|
const runtime = current ? this.turnRuntimes.get(current.id) : undefined;
|
|
149
174
|
runtime?.inputController.closePendingInputs();
|
|
150
|
-
|
|
151
|
-
|
|
175
|
+
// A reserved turn can release its slot synchronously when cancelled.
|
|
176
|
+
// Clear the queue first so cancellation cannot promote an unwanted input.
|
|
177
|
+
const queued = options.cancelQueued ? this.turnCoordinator.clearQueue(sessionId) : 0;
|
|
178
|
+
return queued + this.turnCoordinator.stopCurrent(sessionId);
|
|
179
|
+
}
|
|
180
|
+
/** Stop and wait for persistence, cleanup, and terminal event publication. */
|
|
181
|
+
async stopAndWait(sessionId, options = {}) {
|
|
182
|
+
const owner = this.ownerForSession(sessionId);
|
|
183
|
+
if (owner && owner !== this)
|
|
184
|
+
return owner.stopAndWait(sessionId, options);
|
|
185
|
+
const current = this.turnCoordinator.getCurrent(sessionId);
|
|
186
|
+
const stopping = this.runtimesForSession(sessionId).filter(runtime => options.cancelQueued || runtime.reservation.id === current?.id);
|
|
187
|
+
const stopped = this.stop(sessionId, options);
|
|
188
|
+
await Promise.all(stopping.map(runtime => runtime.finished));
|
|
189
|
+
return stopped;
|
|
152
190
|
}
|
|
153
191
|
/** Cancel queued turns without interrupting the turn that owns the session slot. */
|
|
154
192
|
clearQueue(sessionId) {
|
|
@@ -205,10 +243,10 @@ export class BubbleSdk {
|
|
|
205
243
|
return this.enqueueTurn(sessionId, options);
|
|
206
244
|
}
|
|
207
245
|
// ── Discovery (composer pickers) ─────────────────────────────────────────
|
|
208
|
-
listSkills(cwd) {
|
|
246
|
+
listSkills(cwd, skillPaths = []) {
|
|
209
247
|
const registry = new SkillRegistry({
|
|
210
248
|
cwd: cwd || this.defaultCwd,
|
|
211
|
-
skillPaths: this.userConfig.getSkillPaths(),
|
|
249
|
+
skillPaths: [...this.userConfig.getSkillPaths(), ...skillPaths],
|
|
212
250
|
disabledSkills: this.userConfig.getDisabledSkills(),
|
|
213
251
|
});
|
|
214
252
|
return registry.summaries();
|
|
@@ -284,10 +322,13 @@ export class BubbleSdk {
|
|
|
284
322
|
outcomes: new Map(),
|
|
285
323
|
options,
|
|
286
324
|
events: new ReplayEventLog(),
|
|
325
|
+
started: reservation.phase !== "queued",
|
|
326
|
+
inputRecorded: false,
|
|
327
|
+
finished: Promise.resolve(),
|
|
287
328
|
};
|
|
288
329
|
this.lastTurnOptions.set(sessionId, inheritableTurnOptions(options));
|
|
289
330
|
this.turnRuntimes.set(reservation.id, runtime);
|
|
290
|
-
|
|
331
|
+
runtime.finished = this.pumpTurn(runtime, resolved);
|
|
291
332
|
return runtime;
|
|
292
333
|
}
|
|
293
334
|
async pumpTurn(runtime, resolved) {
|
|
@@ -296,7 +337,7 @@ export class BubbleSdk {
|
|
|
296
337
|
try {
|
|
297
338
|
for await (const event of this.runReservedTurn(runtime, runtime.options, resolved)) {
|
|
298
339
|
runtime.events.append(event);
|
|
299
|
-
// Publish success after the
|
|
340
|
+
// Publish success after the pump's finally releases its slot.
|
|
300
341
|
// A host can then distinguish idle from an automatically queued steer.
|
|
301
342
|
if (event.type === "agent_end")
|
|
302
343
|
completedEvent = event;
|
|
@@ -306,8 +347,29 @@ export class BubbleSdk {
|
|
|
306
347
|
}
|
|
307
348
|
catch (error) {
|
|
308
349
|
failure = error;
|
|
350
|
+
// Setup can fail before runReservedTurn installs its own cleanup.
|
|
351
|
+
for (const event of this.rejectOutstandingSteers(runtime, runtime.reservation.signal.aborted ? "turn_cancelled" : "turn_failed")) {
|
|
352
|
+
runtime.events.append(event);
|
|
353
|
+
this.publishSessionEvent(runtime, event);
|
|
354
|
+
}
|
|
355
|
+
// Desktop users already submitted this input. Even if setup was
|
|
356
|
+
// interrupted before Agent.run appended it, keep it for a later resume.
|
|
357
|
+
// Cancelled queued inputs never owned the turn and must stay discarded.
|
|
358
|
+
if (runtime.options.preserveInterruptedInput && runtime.started && !runtime.inputRecorded
|
|
359
|
+
&& runtime.reservation.signal.aborted && !this.turnCoordinator.isDeleted(runtime.sessionId)) {
|
|
360
|
+
try {
|
|
361
|
+
this.persistInterruptedInput(resolved, runtime.options.prompt);
|
|
362
|
+
}
|
|
363
|
+
catch (persistError) {
|
|
364
|
+
failure = persistError;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
309
367
|
}
|
|
310
368
|
finally {
|
|
369
|
+
runtime.inputController.closePendingInputs();
|
|
370
|
+
// Release only after the interrupted input is durable. Keep the runtime
|
|
371
|
+
// discoverable until the terminal event is published for stopAndWait.
|
|
372
|
+
runtime.reservation.finish();
|
|
311
373
|
if (failure !== undefined) {
|
|
312
374
|
// The session-level log never closes per turn, so replay subscribers
|
|
313
375
|
// need an explicit terminal record — otherwise a failed turn is
|
|
@@ -337,6 +399,8 @@ export class BubbleSdk {
|
|
|
337
399
|
const mode = options.mode ?? "default";
|
|
338
400
|
const planExitMode = options.planExitMode === "bypassPermissions" ? "bypassPermissions" : "default";
|
|
339
401
|
const abortSignal = reservation.signal;
|
|
402
|
+
await reservation.waitForStart();
|
|
403
|
+
runtime.started = true;
|
|
340
404
|
let agentRef;
|
|
341
405
|
let streamCompleted = false;
|
|
342
406
|
const hookController = new ExternalHookController({ cwd, sessionId });
|
|
@@ -399,10 +463,9 @@ export class BubbleSdk {
|
|
|
399
463
|
});
|
|
400
464
|
abortSignal.addEventListener("abort", () => questionController.rejectAll(), { once: true });
|
|
401
465
|
try {
|
|
402
|
-
await reservation.waitForStart();
|
|
403
466
|
const skillRegistry = new SkillRegistry({
|
|
404
467
|
cwd,
|
|
405
|
-
skillPaths: this.userConfig.getSkillPaths(),
|
|
468
|
+
skillPaths: [...this.userConfig.getSkillPaths(), ...(options.skillPaths ?? [])],
|
|
406
469
|
disabledSkills: this.userConfig.getDisabledSkills(),
|
|
407
470
|
});
|
|
408
471
|
const tools = createAllTools(cwd, skillRegistry, {
|
|
@@ -414,6 +477,22 @@ export class BubbleSdk {
|
|
|
414
477
|
checkpoints: () => session.getCheckpoints(),
|
|
415
478
|
});
|
|
416
479
|
tools.push(...gateMcpTools(await awaitWithAbort(this.mcpToolsFor(cwd), abortSignal), approvalController));
|
|
480
|
+
const hostTools = options.hostTools ?? [];
|
|
481
|
+
const registered = new Set(tools.map(tool => tool.name));
|
|
482
|
+
for (const tool of hostTools) {
|
|
483
|
+
if (registered.has(tool.name))
|
|
484
|
+
throw new Error(`Duplicate host tool: ${tool.name}`);
|
|
485
|
+
registered.add(tool.name);
|
|
486
|
+
const guarded = {
|
|
487
|
+
...tool,
|
|
488
|
+
execute: async (args, ctx) => {
|
|
489
|
+
throwAbortSignal(abortSignal);
|
|
490
|
+
return tool.execute(args, { ...ctx, abortSignal });
|
|
491
|
+
},
|
|
492
|
+
cloneForChild: () => ({ ...tool, enabled: () => false, execute: async () => ({ content: "Host capability is not available to this child.", isError: true }) }),
|
|
493
|
+
};
|
|
494
|
+
tools.push(...(tool.readOnly ? [guarded] : gateMcpTools([guarded], approvalController)));
|
|
495
|
+
}
|
|
417
496
|
throwAbortSignal(abortSignal);
|
|
418
497
|
const promptCacheKey = session.getOrCreatePromptCacheKey();
|
|
419
498
|
if (!this.turnCoordinator.isDeleted(sessionId)) {
|
|
@@ -429,6 +508,8 @@ export class BubbleSdk {
|
|
|
429
508
|
const memoryPrompt = buildMemoryPrompt(cwd);
|
|
430
509
|
const builtSystemPrompt = buildSystemPrompt({
|
|
431
510
|
agentName: "Bubble",
|
|
511
|
+
skills: skillRegistry.summaries(),
|
|
512
|
+
skillCatalogChars: this.userConfig.getSkillCatalogChars(),
|
|
432
513
|
configuredProvider: providerId || "none",
|
|
433
514
|
configuredModel: model ? displayModel(model) : "none",
|
|
434
515
|
configuredModelId: model || "none",
|
|
@@ -487,6 +568,7 @@ export class BubbleSdk {
|
|
|
487
568
|
budgetLedger: new BudgetLedger(),
|
|
488
569
|
fileStateTracker,
|
|
489
570
|
skills: skillRegistry.summaries(),
|
|
571
|
+
skillCatalogChars: this.userConfig.getSkillCatalogChars(),
|
|
490
572
|
memoryPrompt,
|
|
491
573
|
externalHooks: hookController,
|
|
492
574
|
// Cross-provider subagent routes (spawn_agent with "provider:model")
|
|
@@ -500,6 +582,8 @@ export class BubbleSdk {
|
|
|
500
582
|
return;
|
|
501
583
|
if (this.turnCoordinator.isDeleted(sessionId))
|
|
502
584
|
return;
|
|
585
|
+
if (message.role === "user")
|
|
586
|
+
runtime.inputRecorded = true;
|
|
503
587
|
persistFenced(() => {
|
|
504
588
|
session.appendMessage(message, contextRevision);
|
|
505
589
|
contextRevision = session.getRevision();
|
|
@@ -576,9 +660,6 @@ export class BubbleSdk {
|
|
|
576
660
|
this.rejectOutstandingSteers(runtime, streamCompleted ? "no_continuation" : abortSignal.aborted ? "turn_cancelled" : "turn_failed", streamCompleted ? resolved : undefined);
|
|
577
661
|
unsubscribeQuestions();
|
|
578
662
|
questionController.rejectAll();
|
|
579
|
-
reservation.finish();
|
|
580
|
-
if (this.turnRuntimes.get(reservation.id) === runtime)
|
|
581
|
-
this.turnRuntimes.delete(reservation.id);
|
|
582
663
|
}
|
|
583
664
|
}
|
|
584
665
|
// ── Internals ────────────────────────────────────────────────────────────
|
package/dist/session.d.ts
CHANGED
|
@@ -114,7 +114,11 @@ export declare class SessionManager {
|
|
|
114
114
|
* Lives next to the session JSONL as `<session>.checkpoints/`.
|
|
115
115
|
*/
|
|
116
116
|
getCheckpoints(): CheckpointStore;
|
|
117
|
-
/**
|
|
117
|
+
/**
|
|
118
|
+
* Entry id of the most recent user turn (as listUserTurns counts them), or
|
|
119
|
+
* "0" before the first one. Checkpoints are keyed by it, so edits made after
|
|
120
|
+
* a tool screenshot or a harness-injected block stay with the user's turn.
|
|
121
|
+
*/
|
|
118
122
|
lastUserEntryId(): string;
|
|
119
123
|
/** User messages after the latest /clear, oldest first — the valid rewind anchors. */
|
|
120
124
|
listUserTurns(): UserTurn[];
|
package/dist/session.js
CHANGED
|
@@ -414,13 +414,20 @@ export class SessionManager {
|
|
|
414
414
|
}
|
|
415
415
|
return this.checkpoints;
|
|
416
416
|
}
|
|
417
|
-
/**
|
|
417
|
+
/**
|
|
418
|
+
* Entry id of the most recent user turn (as listUserTurns counts them), or
|
|
419
|
+
* "0" before the first one. Checkpoints are keyed by it, so edits made after
|
|
420
|
+
* a tool screenshot or a harness-injected block stay with the user's turn.
|
|
421
|
+
*/
|
|
418
422
|
lastUserEntryId() {
|
|
419
423
|
const entries = this.log.list();
|
|
420
424
|
for (let i = entries.length - 1; i >= 0; i--) {
|
|
421
425
|
const entry = entries[i];
|
|
422
|
-
if (entry.type
|
|
423
|
-
|
|
426
|
+
if (entry.type !== "user_message")
|
|
427
|
+
continue;
|
|
428
|
+
if (entry.message.toolObservation || isInternalBlockOnlyContent(entry.message.content))
|
|
429
|
+
continue;
|
|
430
|
+
return entry.id;
|
|
424
431
|
}
|
|
425
432
|
return "0";
|
|
426
433
|
}
|
|
@@ -443,7 +450,8 @@ export class SessionManager {
|
|
|
443
450
|
continue;
|
|
444
451
|
// Harness-injected turns (goal kicks, task wakes) are internal blocks —
|
|
445
452
|
// not rewind anchors, and their markup must never render in the picker.
|
|
446
|
-
|
|
453
|
+
// Tool screenshots are user-role only for the provider; not turns either.
|
|
454
|
+
if (isInternalBlockOnlyContent(entry.message.content) || entry.message.toolObservation)
|
|
447
455
|
continue;
|
|
448
456
|
const text = messageText(entry.message);
|
|
449
457
|
turns.push({
|
|
@@ -617,7 +625,7 @@ function firstUserEntryAfterLatestClear(entries) {
|
|
|
617
625
|
const entry = entries[i];
|
|
618
626
|
// Skip harness-injected internal blocks (goal kicks, task wakes): they
|
|
619
627
|
// must not become the /resume preview or the deterministic title.
|
|
620
|
-
if (entry.type === "user_message" && !isInternalBlockOnlyContent(entry.message.content)) {
|
|
628
|
+
if (entry.type === "user_message" && !isInternalBlockOnlyContent(entry.message.content) && !entry.message.toolObservation) {
|
|
621
629
|
return entry;
|
|
622
630
|
}
|
|
623
631
|
}
|
package/dist/skills/format.d.ts
CHANGED
|
@@ -1,2 +1,32 @@
|
|
|
1
1
|
import type { SkillSummary } from "./types.js";
|
|
2
|
-
|
|
2
|
+
/** Default size of the always-present skill catalog (the listed lines, not the header). */
|
|
3
|
+
export declare const SKILL_CATALOG_CHARS = 12000;
|
|
4
|
+
/** Each description is cut to this length; it only has to tell the model when to load. */
|
|
5
|
+
export declare const SKILL_CATALOG_DESC_CHARS = 120;
|
|
6
|
+
export declare const SKILL_CATALOG_HEADING = "## Skills";
|
|
7
|
+
/** First line under the heading; together they mark the catalog unambiguously. */
|
|
8
|
+
export declare const SKILL_CATALOG_INTRO = "Skills are task-specific instructions loaded on demand. When a task matches a skill below, call `skill` with its exact name before starting, then follow it. Load only what clearly applies.";
|
|
9
|
+
export interface SkillCatalogOptions {
|
|
10
|
+
/** Budget for the listed lines; 0 turns the catalog off. */
|
|
11
|
+
budgetChars?: number;
|
|
12
|
+
/** skill_search is available, so the overflow line can point to it. */
|
|
13
|
+
searchable?: boolean;
|
|
14
|
+
}
|
|
15
|
+
export interface SkillCatalog {
|
|
16
|
+
/** The system-prompt section, or "" when there is nothing to list. */
|
|
17
|
+
text: string;
|
|
18
|
+
listed: number;
|
|
19
|
+
unlisted: number;
|
|
20
|
+
/** Characters used by the listed lines, against budgetChars. */
|
|
21
|
+
chars: number;
|
|
22
|
+
budgetChars: number;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The always-present skill catalog: name and a one-line description per skill,
|
|
26
|
+
* so the model knows what exists and loads a body with `skill` only when it
|
|
27
|
+
* applies. Ordering is deterministic (source, then name) so the system prompt
|
|
28
|
+
* stays byte-identical, and cacheable, while the skill set is unchanged.
|
|
29
|
+
*/
|
|
30
|
+
export declare function buildSkillCatalog(skills: SkillSummary[], options?: SkillCatalogOptions): SkillCatalog;
|
|
31
|
+
/** The catalog section text (see buildSkillCatalog). */
|
|
32
|
+
export declare function formatSkillsPrompt(skills: SkillSummary[], options?: SkillCatalogOptions): string;
|
package/dist/skills/format.js
CHANGED
|
@@ -1,47 +1,68 @@
|
|
|
1
|
-
|
|
2
|
-
const
|
|
1
|
+
/** Default size of the always-present skill catalog (the listed lines, not the header). */
|
|
2
|
+
export const SKILL_CATALOG_CHARS = 12_000;
|
|
3
|
+
/** Each description is cut to this length; it only has to tell the model when to load. */
|
|
4
|
+
export const SKILL_CATALOG_DESC_CHARS = 120;
|
|
5
|
+
export const SKILL_CATALOG_HEADING = "## Skills";
|
|
6
|
+
/** First line under the heading; together they mark the catalog unambiguously. */
|
|
7
|
+
export const SKILL_CATALOG_INTRO = "Skills are task-specific instructions loaded on demand. When a task matches a skill below, call `skill` with its exact name before starting, then follow it. Load only what clearly applies.";
|
|
3
8
|
const SOURCE_PRIORITY = {
|
|
4
9
|
project: 0,
|
|
5
10
|
configured: 1,
|
|
6
11
|
user: 2,
|
|
7
12
|
};
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
13
|
+
/**
|
|
14
|
+
* The always-present skill catalog: name and a one-line description per skill,
|
|
15
|
+
* so the model knows what exists and loads a body with `skill` only when it
|
|
16
|
+
* applies. Ordering is deterministic (source, then name) so the system prompt
|
|
17
|
+
* stays byte-identical, and cacheable, while the skill set is unchanged.
|
|
18
|
+
*/
|
|
19
|
+
export function buildSkillCatalog(skills, options = {}) {
|
|
20
|
+
const budgetChars = Math.max(0, Math.floor(options.budgetChars ?? SKILL_CATALOG_CHARS));
|
|
21
|
+
const empty = { text: "", listed: 0, unlisted: skills.length, chars: 0, budgetChars };
|
|
22
|
+
if (skills.length === 0 || budgetChars === 0)
|
|
23
|
+
return empty;
|
|
11
24
|
const sorted = [...skills].sort((a, b) => {
|
|
12
25
|
const ap = SOURCE_PRIORITY[a.source ?? "user"] ?? 3;
|
|
13
26
|
const bp = SOURCE_PRIORITY[b.source ?? "user"] ?? 3;
|
|
14
27
|
if (ap !== bp)
|
|
15
28
|
return ap - bp;
|
|
16
|
-
|
|
29
|
+
// Code-point order, not locale collation: the prompt must be byte-identical across runtimes.
|
|
30
|
+
return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
|
|
17
31
|
});
|
|
18
|
-
const header = [
|
|
19
|
-
"Skills provide specialized instructions and workflows for specific tasks.",
|
|
20
|
-
"Use the skill system when a task clearly matches a skill description.",
|
|
21
|
-
"Available skills:",
|
|
22
|
-
];
|
|
23
32
|
const lines = [];
|
|
24
33
|
let used = 0;
|
|
25
|
-
let dropped = 0;
|
|
26
34
|
for (const skill of sorted) {
|
|
27
35
|
const line = formatSkillLine(skill);
|
|
28
|
-
if (used + line.length + 1 >
|
|
29
|
-
dropped = sorted.length - lines.length;
|
|
36
|
+
if (used + line.length + 1 > budgetChars)
|
|
30
37
|
break;
|
|
31
|
-
}
|
|
32
38
|
lines.push(line);
|
|
33
39
|
used += line.length + 1;
|
|
34
40
|
}
|
|
35
|
-
if (
|
|
36
|
-
|
|
41
|
+
if (lines.length === 0)
|
|
42
|
+
return empty;
|
|
43
|
+
const unlisted = sorted.length - lines.length;
|
|
44
|
+
if (unlisted > 0) {
|
|
45
|
+
lines.push(options.searchable
|
|
46
|
+
? `- … and ${unlisted} more skills not listed here; use skill_search to find them.`
|
|
47
|
+
: `- … and ${unlisted} more skills not listed here.`);
|
|
37
48
|
}
|
|
38
|
-
|
|
49
|
+
const text = [
|
|
50
|
+
SKILL_CATALOG_HEADING,
|
|
51
|
+
SKILL_CATALOG_INTRO,
|
|
52
|
+
...lines,
|
|
53
|
+
].join("\n");
|
|
54
|
+
return { text, listed: sorted.length - unlisted, unlisted, chars: used, budgetChars };
|
|
55
|
+
}
|
|
56
|
+
/** The catalog section text (see buildSkillCatalog). */
|
|
57
|
+
export function formatSkillsPrompt(skills, options = {}) {
|
|
58
|
+
return buildSkillCatalog(skills, options).text;
|
|
39
59
|
}
|
|
40
60
|
function formatSkillLine(skill) {
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
61
|
+
const raw = (skill.description ?? "").replace(/\s+/g, " ").trim();
|
|
62
|
+
// Cut by code points so an emoji is never split into a lone surrogate, which some APIs reject.
|
|
63
|
+
const points = Array.from(raw);
|
|
64
|
+
const desc = points.length > SKILL_CATALOG_DESC_CHARS
|
|
65
|
+
? points.slice(0, SKILL_CATALOG_DESC_CHARS - 1).join("").trimEnd() + "…"
|
|
66
|
+
: raw;
|
|
67
|
+
return desc ? `- ${skill.name}: ${desc}` : `- ${skill.name}`;
|
|
47
68
|
}
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import type { SlashCommand } from "./types.js";
|
|
1
2
|
import type { UnifiedCommand } from "./unified.js";
|
|
3
|
+
/** Rebuild the system prompt from the agent's live options (model, tools, memory, Skills catalog). */
|
|
4
|
+
export declare function syncSystemPrompt(ctx: Pick<Parameters<SlashCommand["handler"]>[1], "agent" | "cwd">, model: string): void;
|
|
2
5
|
/**
|
|
3
6
|
* Public export — built-in commands tagged with `source: "builtin"` so the
|
|
4
7
|
* registry and TUI can group them uniformly alongside MCP-derived commands.
|