diff --git a/apps/app/src/components/plugin/PluginProviderExtensionStates.tsx b/apps/app/src/components/plugin/PluginProviderExtensionStates.tsx
new file mode 100644
index 0000000000..27b5fee9bb
--- /dev/null
+++ b/apps/app/src/components/plugin/PluginProviderExtensionStates.tsx
@@ -0,0 +1,54 @@
+import type { ThreadTimelineExtensionState } from "@bb/server-contract";
+import { usePluginSlots } from "@/lib/plugin-slots";
+import { sdk } from "@/lib/sdk";
+import { PluginSlotMount } from "./PluginSlotMount";
+
+interface PluginProviderExtensionStatesProps {
+ extensionStates: readonly ThreadTimelineExtensionState[];
+ placement: "aboveEditor" | "belowEditor";
+ providerId: string;
+ threadId: string;
+}
+
+/** Generic mount point; payload interpretation and visuals stay in the owner. */
+export function PluginProviderExtensionStates({
+ extensionStates,
+ placement,
+ providerId,
+ threadId,
+}: PluginProviderExtensionStatesProps) {
+ const { providerExtensionStates } = usePluginSlots();
+
+ return extensionStates.flatMap((state) => {
+ const slot = providerExtensionStates.find(
+ (candidate) => state.kind === `${candidate.pluginId}/${candidate.name}`,
+ );
+ if (!slot) return [];
+ return [
+
+
+ sdk.threads.experimental_applyExtensionStateAction({
+ threadId,
+ kind: state.kind,
+ action,
+ })
+ }
+ />
+ ,
+ ];
+ });
+}
diff --git a/apps/app/src/components/plugin/PluginSettingsSections.tsx b/apps/app/src/components/plugin/PluginSettingsSections.tsx
index 6b367c355f..72e2ea9db6 100644
--- a/apps/app/src/components/plugin/PluginSettingsSections.tsx
+++ b/apps/app/src/components/plugin/PluginSettingsSections.tsx
@@ -4,6 +4,8 @@ import {
} from "@/lib/plugin-slots";
import { PluginSlotMount } from "./PluginSlotMount";
import { ResourceDetailPanel } from "@bb/shared-ui/resource-detail";
+import { selectPrimaryHost, useHosts } from "@/hooks/queries/host-queries";
+import { useSystemConfig } from "@/hooks/queries/system-queries";
/**
* Plugin `settingsSection` slot mounts, rendered on that plugin's canonical
@@ -12,30 +14,47 @@ import { ResourceDetailPanel } from "@bb/shared-ui/resource-detail";
*/
export function PluginSettingsSections({ pluginId }: { pluginId: string }) {
const { settingsSections } = usePluginSlots();
+ const hostsQuery = useHosts();
+ const systemConfigQuery = useSystemConfig();
const sections = settingsSections.filter(
(section) => section.pluginId === pluginId,
);
if (sections.length === 0) return null;
- return ;
+ const selectedHost = selectPrimaryHost(
+ hostsQuery.data,
+ systemConfigQuery.data?.primaryHostId ?? null,
+ );
+ return (
+
+ );
}
function PluginSettingsSectionList({
sections,
+ hostId,
}: {
sections: readonly PluginSettingsSectionSlot[];
+ hostId: string | null;
}) {
return (
{sections.map((section) => {
const key = `${section.pluginId}/${section.id}/${section.generation}`;
return section.title === undefined ? (
-
+
) : (
);
})}
@@ -45,8 +64,10 @@ function PluginSettingsSectionList({
function PluginSettingsSectionPanel({
section,
+ hostId,
}: {
section: PluginSettingsSectionSlot;
+ hostId: string | null;
}) {
return (
@@ -60,7 +81,7 @@ function PluginSettingsSectionPanel({
slotKind="settingsSection"
slotId={section.id}
>
-
+
);
diff --git a/apps/app/src/components/promptbox/FollowUpPromptBox.tsx b/apps/app/src/components/promptbox/FollowUpPromptBox.tsx
index fb71b89ca2..ffd27f2164 100644
--- a/apps/app/src/components/promptbox/FollowUpPromptBox.tsx
+++ b/apps/app/src/components/promptbox/FollowUpPromptBox.tsx
@@ -19,9 +19,11 @@ import type {
ThreadTimelineActivePromptMode,
} from "@bb/domain";
import type { ComposerView, PluginComposerScope } from "@get-bb/plugin-sdk";
+import type { ThreadTimelineExtensionState } from "@bb/server-contract";
import type { ComposerTextEffectSource } from "@/lib/composer-text-effects";
import { isKeyboardFocusTarget } from "@/components/layout/useMobileVisualViewportHeight";
import { ComposerBannersSlot } from "@/components/plugin/PluginComposerBanners";
+import { PluginProviderExtensionStates } from "@/components/plugin/PluginProviderExtensionStates";
import {
PluginComposerHostProvider,
PluginComposerViewProvider,
@@ -216,6 +218,12 @@ export interface FollowUpPromptBoxProps {
suppressPluginComposerCustomizations?: boolean;
/** Optional transient draft host exposed to plugin composer hooks. */
pluginComposerHost?: PluginComposerHost | null;
+ /** Current provider-extension state rendered by its owning plugin. */
+ providerExtensionState?: {
+ extensionStates: readonly ThreadTimelineExtensionState[];
+ providerId: string;
+ threadId: string;
+ };
/** Active scope used to filter and lifecycle-key plugin banner slots. */
pluginComposerScope?: PluginComposerScope | null;
textEffects?: readonly ComposerTextEffectSource[];
@@ -312,6 +320,7 @@ function FollowUpPromptBoxWithComposer({
suppressPluginComposerCustomizations,
pluginComposerHost,
pluginComposerScope,
+ providerExtensionState,
textEffects,
collapseResetKey,
focusEndKey,
@@ -819,6 +828,7 @@ function FollowUpPromptBoxWithComposer({
hasPluginComposerScope={composerScope !== null}
isPrimaryComposer={isPrimaryComposer}
pendingInteraction={pendingInteraction}
+ providerExtensionState={providerExtensionState}
showScrollToBottomButton={showScrollToBottomButton}
stack={stack}
stackRef={stackRef}
@@ -834,6 +844,7 @@ interface DefaultFollowUpComposerProps {
hasPluginComposerScope: boolean;
isPrimaryComposer: boolean;
pendingInteraction?: ReactNode;
+ providerExtensionState?: FollowUpPromptBoxProps["providerExtensionState"];
showScrollToBottomButton: boolean;
stack: ReactNode | null;
stackRef: RefObject
;
@@ -846,6 +857,7 @@ function DefaultFollowUpComposer({
hasPluginComposerScope,
isPrimaryComposer,
pendingInteraction = null,
+ providerExtensionState,
showScrollToBottomButton,
stack,
stackRef,
@@ -867,9 +879,21 @@ function DefaultFollowUpComposer({
) : (
stack
)}
+ {providerExtensionState ? (
+
+ ) : null}
{pendingInteraction}
{composerElement}
+ {providerExtensionState ? (
+
+ ) : null}
>
);
diff --git a/apps/app/src/components/thread/pending-interactions/ThreadPendingInteractionBanner.stories.tsx b/apps/app/src/components/thread/pending-interactions/ThreadPendingInteractionBanner.stories.tsx
index e189f508e5..2e9e9e6edd 100644
--- a/apps/app/src/components/thread/pending-interactions/ThreadPendingInteractionBanner.stories.tsx
+++ b/apps/app/src/components/thread/pending-interactions/ThreadPendingInteractionBanner.stories.tsx
@@ -19,8 +19,8 @@ function PromptStage({ children }: { children: React.ReactNode }) {
// The common fields; each story pairs its own payload with its resolution.
function basePendingInteraction(): Omit<
ProviderPendingInteraction,
- "payload" | "resolution"
-> {
+ "payload" | "resolution" | "turnId"
+> & { turnId: string } {
return {
id: "pi_demo",
threadId: "thr_qfk8ksbxkk",
diff --git a/apps/app/src/components/thread/timeline/ThreadTimelinePanelContent.test.tsx b/apps/app/src/components/thread/timeline/ThreadTimelinePanelContent.test.tsx
index 1febeb08de..887629d029 100644
--- a/apps/app/src/components/thread/timeline/ThreadTimelinePanelContent.test.tsx
+++ b/apps/app/src/components/thread/timeline/ThreadTimelinePanelContent.test.tsx
@@ -48,6 +48,7 @@ vi.mock("./useThreadTimelineController.js", () => ({
activeWorkflows: [],
activeBackgroundCommands: [],
contextWindowUsage: undefined,
+ extensionStates: [],
goal: null,
modelFallback: null,
hasOlderTimelineRows: false,
@@ -101,6 +102,7 @@ function baseTimeline(
activeWorkflows: [],
activeBackgroundCommands: [],
contextWindowUsage: undefined,
+ extensionStates: [],
goal: null,
hasOlderTimelineRows: false,
isLoadingOlderTimelineRows: false,
diff --git a/apps/app/src/components/thread/timeline/useThreadTimelineController.test.tsx b/apps/app/src/components/thread/timeline/useThreadTimelineController.test.tsx
index 3190db9f6c..c3d68dd2cc 100644
--- a/apps/app/src/components/thread/timeline/useThreadTimelineController.test.tsx
+++ b/apps/app/src/components/thread/timeline/useThreadTimelineController.test.tsx
@@ -41,6 +41,7 @@ function makeTimelineResponse(): ThreadTimelineResponse {
activeThinking: null,
activeWorkflows: [],
activeBackgroundCommands: [],
+ extensionStates: [],
pendingTodos: null,
goal: null,
modelFallback: null,
diff --git a/apps/app/src/components/thread/timeline/useThreadTimelineController.ts b/apps/app/src/components/thread/timeline/useThreadTimelineController.ts
index d816de331e..05242cb08d 100644
--- a/apps/app/src/components/thread/timeline/useThreadTimelineController.ts
+++ b/apps/app/src/components/thread/timeline/useThreadTimelineController.ts
@@ -25,6 +25,7 @@ export interface UseThreadTimelineControllerResult {
activeWorkflows: ThreadTimelineResponse["activeWorkflows"];
activeBackgroundCommands: ThreadTimelineResponse["activeBackgroundCommands"];
contextWindowUsage: ThreadTimelineResponse["contextWindowUsage"];
+ extensionStates: ThreadTimelineResponse["extensionStates"];
goal: ThreadTimelineResponse["goal"];
modelFallback: ThreadTimelineResponse["modelFallback"];
hasOlderTimelineRows: boolean;
@@ -200,6 +201,7 @@ export function useThreadTimelineController({
activeWorkflows: latestTimeline?.activeWorkflows ?? [],
activeBackgroundCommands: latestTimeline?.activeBackgroundCommands ?? [],
contextWindowUsage: latestTimeline?.contextWindowUsage,
+ extensionStates: latestTimeline?.extensionStates ?? [],
goal: latestTimeline?.goal ?? null,
modelFallback: latestTimeline?.modelFallback ?? null,
hasOlderTimelineRows,
diff --git a/apps/app/src/components/thread/user-questions/UserQuestionInteractionContent.tsx b/apps/app/src/components/thread/user-questions/UserQuestionInteractionContent.tsx
index e354727d8a..f6cdb538e6 100644
--- a/apps/app/src/components/thread/user-questions/UserQuestionInteractionContent.tsx
+++ b/apps/app/src/components/thread/user-questions/UserQuestionInteractionContent.tsx
@@ -134,7 +134,9 @@ function QuestionOptionRow({
{checked ? : null}
- {label}
+
+ {label}
+
{description ? (
{description}
@@ -212,12 +214,7 @@ function QuestionInputBlock({
useLayoutEffect(() => {
if (!state.otherSelected) return;
resizeFreeTextArea();
- }, [
- question.id,
- resizeFreeTextArea,
- state.otherSelected,
- state.otherText,
- ]);
+ }, [question.id, resizeFreeTextArea, state.otherSelected, state.otherText]);
const handleFreeTextKeyDown = (
event: KeyboardEvent,
@@ -275,7 +272,9 @@ function QuestionInputBlock({
resizeFreeTextArea(event.target);
}}
onKeyDown={handleFreeTextKeyDown}
- placeholder="Type your own answer…"
+ placeholder={
+ question.experimental_placeholder ?? "Type your own answer…"
+ }
className="mt-2 w-full resize-none overflow-y-auto rounded-md border border-border bg-surface-raised px-3 py-2 text-sm leading-relaxed text-foreground placeholder:text-muted-foreground focus-visible:border-ring/50 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring/40"
style={{
minHeight: `${USER_QUESTION_FREE_TEXT_MIN_HEIGHT}px`,
diff --git a/apps/app/src/components/thread/user-questions/user-question-form-state.test.ts b/apps/app/src/components/thread/user-questions/user-question-form-state.test.ts
index f725d256e1..14aa71826d 100644
--- a/apps/app/src/components/thread/user-questions/user-question-form-state.test.ts
+++ b/apps/app/src/components/thread/user-questions/user-question-form-state.test.ts
@@ -35,6 +35,15 @@ const freeTextOnly: PendingInteractionUserQuestionQuestion = {
allowFreeText: true,
};
+const verbatimText: PendingInteractionUserQuestionQuestion = {
+ id: "exact",
+ prompt: "Exact value",
+ multiSelect: false,
+ allowFreeText: true,
+ experimental_responseMode: "verbatim",
+ experimental_prefill: " initial\nvalue ",
+};
+
describe("buildUserAnswerResolution", () => {
it("returns the selected option for a single-select choice", () => {
const state = createInitialFormState([singleSelect]);
@@ -51,9 +60,9 @@ describe("buildUserAnswerResolution", () => {
state.branch.otherSelected = true;
state.branch.otherText = " a custom branch ";
- expect(buildUserAnswerResolution([singleSelect], state).answers.branch).toEqual(
- { selected: [], freeText: "a custom branch" },
- );
+ expect(
+ buildUserAnswerResolution([singleSelect], state).answers.branch,
+ ).toEqual({ selected: [], freeText: "a custom branch" });
});
it("omits free text when Other is selected but blank", () => {
@@ -61,9 +70,9 @@ describe("buildUserAnswerResolution", () => {
state.branch.otherSelected = true;
state.branch.otherText = " ";
- expect(buildUserAnswerResolution([singleSelect], state).answers.branch).toEqual(
- { selected: [] },
- );
+ expect(
+ buildUserAnswerResolution([singleSelect], state).answers.branch,
+ ).toEqual({ selected: [] });
});
it("keeps both options and free text for multi-select", () => {
@@ -72,18 +81,18 @@ describe("buildUserAnswerResolution", () => {
state.areas.otherSelected = true;
state.areas.otherText = "docs";
- expect(buildUserAnswerResolution([multiSelect], state).answers.areas).toEqual(
- { selected: ["app", "cli"], freeText: "docs" },
- );
+ expect(
+ buildUserAnswerResolution([multiSelect], state).answers.areas,
+ ).toEqual({ selected: ["app", "cli"], freeText: "docs" });
});
it("drops option values that aren't part of the question", () => {
const state = createInitialFormState([singleSelect]);
state.branch.selected = ["main", "ghost"];
- expect(buildUserAnswerResolution([singleSelect], state).answers.branch).toEqual(
- { selected: ["main"] },
- );
+ expect(
+ buildUserAnswerResolution([singleSelect], state).answers.branch,
+ ).toEqual({ selected: ["main"] });
});
it("captures free text for an options-less question", () => {
@@ -92,9 +101,19 @@ describe("buildUserAnswerResolution", () => {
expect(state.notes.otherSelected).toBe(true);
state.notes.otherText = "ship it";
- expect(buildUserAnswerResolution([freeTextOnly], state).answers.notes).toEqual(
- { selected: [], freeText: "ship it" },
- );
+ expect(
+ buildUserAnswerResolution([freeTextOnly], state).answers.notes,
+ ).toEqual({ selected: [], freeText: "ship it" });
+ });
+
+ it("preserves editor prefill and its submitted replacement byte-for-byte", () => {
+ const state = createInitialFormState([verbatimText]);
+ expect(state.exact.otherText).toBe(" initial\nvalue ");
+ state.exact.otherText = " exact\nvalue ";
+
+ expect(
+ buildUserAnswerResolution([verbatimText], state).answers.exact,
+ ).toEqual({ selected: [], experimental_verbatimText: " exact\nvalue " });
});
});
@@ -119,6 +138,16 @@ describe("isQuestionAnswered", () => {
).toBe(true);
});
+ it("accepts an empty string for a verbatim input", () => {
+ expect(
+ isQuestionAnswered(verbatimText, {
+ selected: [],
+ otherSelected: true,
+ otherText: "",
+ }),
+ ).toBe(true);
+ });
+
it("is not answered when Other is selected but blank", () => {
expect(
isQuestionAnswered(singleSelect, {
diff --git a/apps/app/src/components/thread/user-questions/user-question-form-state.ts b/apps/app/src/components/thread/user-questions/user-question-form-state.ts
index e32872325f..262cafc2d9 100644
--- a/apps/app/src/components/thread/user-questions/user-question-form-state.ts
+++ b/apps/app/src/components/thread/user-questions/user-question-form-state.ts
@@ -36,7 +36,7 @@ export function createInitialFormState(
selected: [],
// A question with no options is pure free text — "Other" is implicit.
otherSelected: !questionHasOptions(question),
- otherText: "",
+ otherText: question.experimental_prefill ?? "",
};
}
return state;
@@ -50,7 +50,7 @@ export function answerStateFor(
formState[question.id] ?? {
selected: [],
otherSelected: !questionHasOptions(question),
- otherText: "",
+ otherText: question.experimental_prefill ?? "",
}
);
}
@@ -69,6 +69,9 @@ export function isQuestionAnswered(
question: PendingInteractionUserQuestionQuestion,
state: QuestionAnswerState,
): boolean {
+ if (question.experimental_responseMode === "verbatim") {
+ return state.otherSelected;
+ }
if (validSelectedValues(question, state.selected).length > 0) {
return true;
}
@@ -79,6 +82,13 @@ function buildQuestionAnswer(
question: PendingInteractionUserQuestionQuestion,
state: QuestionAnswerState,
): PendingInteractionUserAnswer {
+ if (question.experimental_responseMode === "verbatim") {
+ return {
+ selected: [],
+ experimental_verbatimText: state.otherText,
+ };
+ }
+
const freeText = state.otherText.trim();
const includeFreeText = state.otherSelected && freeText.length > 0;
if (question.multiSelect) {
diff --git a/apps/app/src/hooks/cache-owners/realtime-cache-registry.ts b/apps/app/src/hooks/cache-owners/realtime-cache-registry.ts
index a4a849dee4..ff2ed9890c 100644
--- a/apps/app/src/hooks/cache-owners/realtime-cache-registry.ts
+++ b/apps/app/src/hooks/cache-owners/realtime-cache-registry.ts
@@ -632,6 +632,9 @@ export const REALTIME_SYSTEM_CHANGE_REGISTRY = {
dirtySystemExecutionOptionQueries, // Refresh changed or boot-time partial provider rosters.
],
},
+ "provider-models-changed": {
+ dirty: [dirtySystemExecutionOptionQueries],
+ },
} satisfies SystemChangeRegistry;
type ThreadChangeFlushPriority = "debounced" | "immediate";
diff --git a/apps/app/src/hooks/cache-owners/thread-detail-cache-owner.ts b/apps/app/src/hooks/cache-owners/thread-detail-cache-owner.ts
index 78f36c5f9d..e92b786316 100644
--- a/apps/app/src/hooks/cache-owners/thread-detail-cache-owner.ts
+++ b/apps/app/src/hooks/cache-owners/thread-detail-cache-owner.ts
@@ -1,6 +1,6 @@
import type { QueryClient } from "@tanstack/react-query";
-import type { Host } from "@bb/domain";
import type {
+ HostResponse,
ThreadResponse,
ThreadWithIncludesResponse,
} from "@bb/server-contract";
@@ -11,11 +11,11 @@ import {
threadQueryKey,
} from "../queries/query-keys";
-type HostList = Host[];
+type HostList = HostResponse[];
type HostListQueryData = HostList | undefined;
interface UpsertHostListArgs {
- host: Host;
+ host: HostResponse;
hosts: HostListQueryData;
}
diff --git a/apps/app/src/hooks/cache-owners/thread-runtime-cache-owner.test.ts b/apps/app/src/hooks/cache-owners/thread-runtime-cache-owner.test.ts
index 4be042c9d3..7db0ddad6a 100644
--- a/apps/app/src/hooks/cache-owners/thread-runtime-cache-owner.test.ts
+++ b/apps/app/src/hooks/cache-owners/thread-runtime-cache-owner.test.ts
@@ -42,6 +42,7 @@ function makeTimelineResponse(): ThreadTimelineResponse {
activeThinking: null,
activeWorkflows: [],
activeBackgroundCommands: [],
+ extensionStates: [],
pendingTodos: null,
goal: null,
modelFallback: null,
@@ -340,9 +341,9 @@ describe("thread runtime cache owner", () => {
expect(
queryClient
- .getQueryData<
- ThreadQueuedMessage[]
- >(threadQueuedMessagesQueryKey("thread-1"))
+ .getQueryData(
+ threadQueuedMessagesQueryKey("thread-1"),
+ )
?.map((queuedMessage) => queuedMessage.id),
).toEqual(["qmsg-existing", "qmsg-server"]);
expect(
@@ -415,9 +416,9 @@ describe("thread runtime cache owner", () => {
});
expect(
queryClient
- .getQueryData<
- ThreadQueuedMessage[]
- >(threadQueuedMessagesQueryKey("thread-1"))
+ .getQueryData(
+ threadQueuedMessagesQueryKey("thread-1"),
+ )
?.map((queuedMessage) => ({
content: queuedMessage.content,
groupWithNext: queuedMessage.groupWithNext,
@@ -445,9 +446,9 @@ describe("thread runtime cache owner", () => {
});
expect(
queryClient
- .getQueryData<
- ThreadQueuedMessage[]
- >(threadQueuedMessagesQueryKey("thread-1"))
+ .getQueryData(
+ threadQueuedMessagesQueryKey("thread-1"),
+ )
?.map((queuedMessage) => queuedMessage.id),
).toEqual(["qmsg-first", "qmsg-edit", "qmsg-last"]);
@@ -655,9 +656,9 @@ describe("thread runtime cache owner", () => {
expect(
queryClient
- .getQueryData<
- ThreadQueuedMessage[]
- >(threadQueuedMessagesQueryKey("thread-1"))
+ .getQueryData(
+ threadQueuedMessagesQueryKey("thread-1"),
+ )
?.map((queuedMessage) => queuedMessage.id),
).toEqual(["qmsg-2"]);
@@ -900,9 +901,9 @@ describe("thread runtime cache owner", () => {
expect(
queryClient
- .getQueryData<
- ThreadQueuedMessage[]
- >(threadQueuedMessagesQueryKey("thread-1"))
+ .getQueryData(
+ threadQueuedMessagesQueryKey("thread-1"),
+ )
?.map((queuedMessage) => queuedMessage.id),
).toEqual(["qmsg-3"]);
const timeline = queryClient.getQueryData(
diff --git a/apps/app/src/hooks/mutations/thread-runtime-mutations.test.tsx b/apps/app/src/hooks/mutations/thread-runtime-mutations.test.tsx
index ff709ccd5d..a5fd8e9e62 100644
--- a/apps/app/src/hooks/mutations/thread-runtime-mutations.test.tsx
+++ b/apps/app/src/hooks/mutations/thread-runtime-mutations.test.tsx
@@ -79,6 +79,7 @@ function makeBannerTimeline(): ThreadTimelineResponse {
activeThinking: null,
activeWorkflows: [],
activeBackgroundCommands: [],
+ extensionStates: [],
pendingTodos: null,
goal: {
sourceSeq: 1,
@@ -370,9 +371,9 @@ describe("thread runtime mutations", () => {
expect(
queryClient
- .getQueryData<
- ThreadQueuedMessage[]
- >(threadQueuedMessagesQueryKey("thread-1"))
+ .getQueryData(
+ threadQueuedMessagesQueryKey("thread-1"),
+ )
?.map((queuedMessage) => queuedMessage.id),
).toEqual(["qmsg-2"]);
});
diff --git a/apps/app/src/hooks/queries/query-helpers.test.ts b/apps/app/src/hooks/queries/query-helpers.test.ts
index bd871d38da..96b68cd1ac 100644
--- a/apps/app/src/hooks/queries/query-helpers.test.ts
+++ b/apps/app/src/hooks/queries/query-helpers.test.ts
@@ -183,6 +183,7 @@ function makeThreadTimelineResponse(
activeThinking: null,
activeWorkflows: [],
activeBackgroundCommands: [],
+ extensionStates: [],
pendingTodos: null,
goal: null,
modelFallback: null,
diff --git a/apps/app/src/hooks/queries/thread-queries.test.tsx b/apps/app/src/hooks/queries/thread-queries.test.tsx
index 075fdddf16..d451936660 100644
--- a/apps/app/src/hooks/queries/thread-queries.test.tsx
+++ b/apps/app/src/hooks/queries/thread-queries.test.tsx
@@ -158,6 +158,7 @@ beforeEach(() => {
activeThinking: null,
activeWorkflows: [],
activeBackgroundCommands: [],
+ extensionStates: [],
pendingTodos: null,
goal: null,
modelFallback: null,
@@ -232,6 +233,7 @@ describe("useThreadDetailBootstrap", () => {
activeThinking: null,
activeWorkflows: [],
activeBackgroundCommands: [],
+ extensionStates: [],
pendingTodos: null,
goal: null,
modelFallback: null,
diff --git a/apps/app/src/hooks/useCommandSuggestions.prefetch.test.tsx b/apps/app/src/hooks/useCommandSuggestions.prefetch.test.tsx
index c66f4d126d..1c34f1b281 100644
--- a/apps/app/src/hooks/useCommandSuggestions.prefetch.test.tsx
+++ b/apps/app/src/hooks/useCommandSuggestions.prefetch.test.tsx
@@ -42,7 +42,10 @@ const BASE_ARGS = {
};
beforeEach(() => {
- vi.mocked(sdk.projects.commands).mockResolvedValue({ commands: [] });
+ vi.mocked(sdk.projects.commands).mockResolvedValue({
+ commands: [],
+ diagnostics: [],
+ });
});
afterEach(() => {
diff --git a/apps/app/src/lib/plugin-slots.ts b/apps/app/src/lib/plugin-slots.ts
index fbcb1085e1..3f37544c50 100644
--- a/apps/app/src/lib/plugin-slots.ts
+++ b/apps/app/src/lib/plugin-slots.ts
@@ -2,6 +2,7 @@ import { useSyncExternalStore } from "react";
import type {
ComposerCustomization,
ExperimentalChangesViewRegistration,
+ ExperimentalProviderExtensionStateRegistration,
ExperimentalSidebarNavigationRegistration,
PluginDiffRendererRegistration,
PluginPendingInteractionRegistration,
@@ -67,6 +68,8 @@ export interface PluginRegistrationSet {
providerIcons?: readonly PluginProviderIconRegistration[];
/** Optional for the same reason as `threadLists`: bundles built earlier. */
timelineRenderers?: readonly PluginTimelineRendererRegistration[];
+ /** Optional for bundles built before provider extension-state rendering. */
+ providerExtensionStates?: readonly ExperimentalProviderExtensionStateRegistration[];
}
interface PluginSlotBase {
@@ -120,6 +123,8 @@ interface PluginProviderIconSlot
extends PluginProviderIconRegistration, PluginSlotBase {}
export interface PluginTimelineRendererSlot
extends PluginTimelineRendererRegistration, PluginSlotBase {}
+export interface PluginProviderExtensionStateSlot
+ extends ExperimentalProviderExtensionStateRegistration, PluginSlotBase {}
/** Flattened view across plugins, ordered by plugin id (deterministic). */
export interface PluginSlotSnapshot {
@@ -143,6 +148,7 @@ export interface PluginSlotSnapshot {
commandPaletteActions: readonly PluginCommandPaletteActionSlot[];
providerIcons: readonly PluginProviderIconSlot[];
timelineRenderers: readonly PluginTimelineRendererSlot[];
+ providerExtensionStates: readonly PluginProviderExtensionStateSlot[];
}
export const EMPTY_PLUGIN_SLOT_SNAPSHOT: PluginSlotSnapshot = {
@@ -166,6 +172,7 @@ export const EMPTY_PLUGIN_SLOT_SNAPSHOT: PluginSlotSnapshot = {
commandPaletteActions: [],
providerIcons: [],
timelineRenderers: [],
+ providerExtensionStates: [],
};
const registrationsByPluginId = new Map();
@@ -196,6 +203,7 @@ const SLOT_KINDS: readonly SlotKind[] = [
"commandPaletteActions",
"providerIcons",
"timelineRenderers",
+ "providerExtensionStates",
];
/**
@@ -247,6 +255,7 @@ function flattenRegistrations(
commandPaletteActions: stamp(set.commandPaletteActions),
providerIcons: stamp(set.providerIcons),
timelineRenderers: stamp(set.timelineRenderers),
+ providerExtensionStates: stamp(set.providerExtensionStates),
};
}
diff --git a/apps/app/src/views/thread-detail/SplitThreadArea.stories.tsx b/apps/app/src/views/thread-detail/SplitThreadArea.stories.tsx
index 050e203293..9b8e5fc4d3 100644
--- a/apps/app/src/views/thread-detail/SplitThreadArea.stories.tsx
+++ b/apps/app/src/views/thread-detail/SplitThreadArea.stories.tsx
@@ -97,6 +97,7 @@ function storyTimeline(
activeThinking: null,
activeWorkflows: [],
activeBackgroundCommands: [],
+ extensionStates: [],
pendingTodos: null,
goal,
modelFallback: null,
diff --git a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.keystrokes.test.tsx b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.keystrokes.test.tsx
index 250054c777..da8cf154c3 100644
--- a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.keystrokes.test.tsx
+++ b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.keystrokes.test.tsx
@@ -400,6 +400,7 @@ function buildPromptArea({
childPendingInteractions={[]}
childThreadsSection={null}
composerEscapeBehavior="blur"
+ extensionStates={[]}
composerFocusRequestNonce={0}
contextBannerMergeBase={null}
environmentGoneStatus={null}
diff --git a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx
index 2c444e4d58..f89b69b998 100644
--- a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx
+++ b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx
@@ -722,6 +722,7 @@ function buildPromptAreaElement({
composerFocusRequestNonce={0}
contextBannerMergeBase={null}
environmentGoneStatus={null}
+ extensionStates={[]}
goal={goal}
modelFallback={modelFallback}
isEnvironmentActionPending={false}
diff --git a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx
index c1ed3c4865..03c3f0e650 100644
--- a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx
+++ b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx
@@ -198,6 +198,8 @@ interface ThreadDetailPromptAreaProps {
activePromptMode: ThreadTimelineActivePromptMode | null;
/** Current provider goal from the timeline projection. Null when no goal is active. */
goal: ThreadTimelineGoal | null;
+ /** Latest plugin-declared provider state, one snapshot per kind. */
+ extensionStates: ThreadTimelineResponse["extensionStates"];
/** Active provider fallback; controls the next model selection until another turn is requested. */
modelFallback: ThreadTimelineModelFallback | null;
/**
@@ -436,6 +438,7 @@ export function ThreadDetailPromptArea({
pendingTodos,
activePromptMode,
goal,
+ extensionStates,
modelFallback,
activeWorkflows,
activeBackgroundCommands,
@@ -1775,6 +1778,11 @@ export function ThreadDetailPromptArea({
composer={shouldHideComposer ? null : bottomComposerConfig}
pluginComposerHost={normalPluginComposerHost}
pluginComposerScope={normalPluginComposerHost.scope}
+ providerExtensionState={{
+ extensionStates,
+ providerId: thread.providerId,
+ threadId: thread.id,
+ }}
textEffects={promptTextEffects}
collapseResetKey={thread.id}
focusEndKey={bottomFocusEndKey}
diff --git a/apps/app/src/views/thread-detail/ThreadDetailView.tsx b/apps/app/src/views/thread-detail/ThreadDetailView.tsx
index e17d3da0bd..f626ef51f4 100644
--- a/apps/app/src/views/thread-detail/ThreadDetailView.tsx
+++ b/apps/app/src/views/thread-detail/ThreadDetailView.tsx
@@ -862,6 +862,7 @@ function ThreadDetailViewInternal(props: ThreadRoutePathArgs) {
activeWorkflows,
activeBackgroundCommands,
contextWindowUsage,
+ extensionStates,
goal,
hasOlderTimelineRows,
isLoadingOlderTimelineRows,
@@ -2613,6 +2614,7 @@ function ThreadDetailViewInternal(props: ThreadRoutePathArgs) {
pendingTodos={pendingTodos}
activePromptMode={activePromptMode}
goal={goal}
+ extensionStates={extensionStates}
modelFallback={modelFallback}
activeWorkflows={activeWorkflows}
activeBackgroundCommands={activeBackgroundCommands}
diff --git a/apps/cli/src/__tests__/helpers/command-output-fixtures.ts b/apps/cli/src/__tests__/helpers/command-output-fixtures.ts
index 301fb76a77..4bfbf532b7 100644
--- a/apps/cli/src/__tests__/helpers/command-output-fixtures.ts
+++ b/apps/cli/src/__tests__/helpers/command-output-fixtures.ts
@@ -41,9 +41,11 @@ interface MakeEnvironmentArgs extends Partial {
* mismatched pair is a fixture bug and throws.
*/
type MakePendingInteractionArgs = Partial<
- Omit
+ Omit
> & {
id: string;
+ /** A fixture's question is turn-scoped unless a test says otherwise. */
+ turnId?: string;
providerId: string;
threadId: string;
payload?: ProviderPendingInteraction["payload"];
@@ -81,6 +83,7 @@ export function makeTimelineResponse(
activeThinking: null,
activeWorkflows: [],
activeBackgroundCommands: [],
+ extensionStates: [],
pendingTodos: null,
goal: null,
modelFallback: null,
diff --git a/apps/cli/src/commands/thread/interactions.ts b/apps/cli/src/commands/thread/interactions.ts
index d8d04af483..f91dfec5de 100644
--- a/apps/cli/src/commands/thread/interactions.ts
+++ b/apps/cli/src/commands/thread/interactions.ts
@@ -210,7 +210,10 @@ function printUserQuestionInteraction(
);
const parts = [
...selectedLabels,
- ...(answer.freeText ? [answer.freeText] : []),
+ ...(answer.freeText !== undefined ? [answer.freeText] : []),
+ ...(answer.experimental_verbatimText !== undefined
+ ? [answer.experimental_verbatimText]
+ : []),
];
console.log(
` ${question.shortLabel ?? question.prompt}: ${parts.join(", ")}`,
@@ -475,7 +478,10 @@ function validateAnswerText(
if (trimmed.length === 0) {
throw new Error(`Question '${question.id}' free text cannot be empty.`);
}
- if (answer.freeText !== undefined) {
+ if (
+ answer.freeText !== undefined ||
+ answer.experimental_verbatimText !== undefined
+ ) {
throw new Error(
`Question '${question.id}' has multiple free-text answers.`,
);
@@ -527,7 +533,19 @@ function buildUserAnswerResolution({
);
}
const answer = answers[textAnswer.questionId] ?? { selected: [] };
- answer.freeText = validateAnswerText(question, answer, textAnswer.value);
+ if (question.experimental_responseMode === "verbatim") {
+ if (
+ answer.freeText !== undefined ||
+ answer.experimental_verbatimText !== undefined
+ ) {
+ throw new Error(
+ `Question '${question.id}' has multiple free-text answers.`,
+ );
+ }
+ answer.experimental_verbatimText = textAnswer.value;
+ } else {
+ answer.freeText = validateAnswerText(question, answer, textAnswer.value);
+ }
answers[textAnswer.questionId] = answer;
}
@@ -535,7 +553,9 @@ function buildUserAnswerResolution({
const answer = answers[question.id];
if (
!answer ||
- (answer.selected.length === 0 && answer.freeText === undefined)
+ (answer.selected.length === 0 &&
+ answer.freeText === undefined &&
+ answer.experimental_verbatimText === undefined)
) {
throw new Error(`Missing answer for question '${question.id}'.`);
}
diff --git a/apps/cli/src/commands/thread/pending-todos.test.ts b/apps/cli/src/commands/thread/pending-todos.test.ts
index 553904a777..aba562a7b2 100644
--- a/apps/cli/src/commands/thread/pending-todos.test.ts
+++ b/apps/cli/src/commands/thread/pending-todos.test.ts
@@ -109,6 +109,7 @@ describe("fetchThreadPendingTodos", () => {
activeThinking: null,
activeWorkflows: [],
activeBackgroundCommands: [],
+ extensionStates: [],
pendingTodos,
goal: null,
modelFallback: null,
diff --git a/apps/demo-server/src/demo-world.ts b/apps/demo-server/src/demo-world.ts
index 6cb6bc66d6..95a230b588 100644
--- a/apps/demo-server/src/demo-world.ts
+++ b/apps/demo-server/src/demo-world.ts
@@ -448,6 +448,7 @@ export class DemoWorld {
activeThinking: null,
activeWorkflows: [],
activeBackgroundCommands: [],
+ extensionStates: [],
pendingTodos: null,
goal: null,
modelFallback: null,
diff --git a/apps/host-daemon/src/app.test.ts b/apps/host-daemon/src/app.test.ts
index 87d66aa8e2..74e1337e78 100644
--- a/apps/host-daemon/src/app.test.ts
+++ b/apps/host-daemon/src/app.test.ts
@@ -250,6 +250,9 @@ function createFakeRuntime(): AgentRuntime {
async stopThread() {
return { providerCheckpointId: null };
},
+ async applyExtensionAction() {
+ return { applied: true };
+ },
async clearThreadGoal() {
return { cleared: true };
},
@@ -262,6 +265,9 @@ function createFakeRuntime(): AgentRuntime {
selectedOnlyModels: [],
};
},
+ async listProviderCommands() {
+ return { supported: false as const };
+ },
async providerHealth() {
return { supported: false as const };
},
diff --git a/apps/host-daemon/src/app.ts b/apps/host-daemon/src/app.ts
index 6026245d66..0508d26aef 100644
--- a/apps/host-daemon/src/app.ts
+++ b/apps/host-daemon/src/app.ts
@@ -412,6 +412,13 @@ export async function createHostDaemonApp(
});
const interactiveRequestRegistry = new InteractiveRequestRegistry({
+ onCancellation: (request, reason) => {
+ enqueueInteractiveInterrupt({
+ providerId: request.providerId,
+ reason,
+ threadIds: [request.threadId],
+ });
+ },
registerRequest: (request) =>
runSessionRequest({
source: "registerInteractiveRequest",
@@ -584,9 +591,12 @@ export async function createHostDaemonApp(
throw error;
}
}),
- onInteractiveRequest: async (request) => {
+ onInteractiveRequest: async (request, signal) => {
try {
- return await interactiveRequestRegistry.registerAndWait(request);
+ return await interactiveRequestRegistry.registerAndWait(
+ request,
+ signal,
+ );
} catch (error) {
if (
error instanceof InteractiveRequestRegistryError &&
@@ -772,6 +782,27 @@ export async function createHostDaemonApp(
(runtime) => runtime.listModels(args),
);
},
+ providerCustomCall: async (args) => {
+ await refreshRuntimeShellEnv();
+ return runtimeManager.withProviderMaintenanceRuntime(
+ { dataDir: options.dataDir },
+ (runtime) => {
+ if (runtime.providerCustomCall === undefined) {
+ throw new Error(
+ "Provider runtime does not support custom bridge calls",
+ );
+ }
+ return runtime.providerCustomCall(args);
+ },
+ );
+ },
+ listProviderCommands: async (args) => {
+ await refreshRuntimeShellEnv();
+ return runtimeManager.withProviderMaintenanceRuntime(
+ { dataDir: options.dataDir },
+ (runtime) => runtime.listProviderCommands(args),
+ );
+ },
providerHealth: async (args) => {
await refreshRuntimeShellEnv();
return runtimeManager.withProviderMaintenanceRuntime(
diff --git a/apps/host-daemon/src/command-dispatch-support.ts b/apps/host-daemon/src/command-dispatch-support.ts
index 1e140dba64..54a53925d9 100644
--- a/apps/host-daemon/src/command-dispatch-support.ts
+++ b/apps/host-daemon/src/command-dispatch-support.ts
@@ -1,5 +1,5 @@
import type { AgentRuntimeBridgeLaunch } from "@bb/agent-runtime";
-import type { AvailableModel } from "@bb/domain";
+import type { AvailableModel, JsonValue } from "@bb/domain";
import type { EventSinkInput } from "./event-sink.js";
import type {
HostDaemonCommand,
@@ -12,6 +12,7 @@ import type {
WorkspaceContext,
} from "@bb/host-daemon-contract";
import type {
+ ExperimentalProviderCommandListResult,
ProviderInstallationCommand,
ProviderInstallationRunResult,
ProviderInstallationStatus,
@@ -63,6 +64,17 @@ export interface CommandDispatchOptions {
models: AvailableModel[];
selectedOnlyModels: AvailableModel[];
}>;
+ providerCustomCall: (args: {
+ providerId: string;
+ bridgeLaunch: AgentRuntimeBridgeLaunch;
+ method: string;
+ input: JsonValue;
+ }) => Promise;
+ listProviderCommands: (args: {
+ providerId: string;
+ bridgeLaunch: AgentRuntimeBridgeLaunch;
+ cwd: string;
+ }) => Promise;
providerHealth: (args: {
providerId: string;
bridgeLaunch: AgentRuntimeBridgeLaunch;
diff --git a/apps/host-daemon/src/command-dispatch.test.ts b/apps/host-daemon/src/command-dispatch.test.ts
index 6077164c29..c27a5bc369 100644
--- a/apps/host-daemon/src/command-dispatch.test.ts
+++ b/apps/host-daemon/src/command-dispatch.test.ts
@@ -192,6 +192,7 @@ function createRuntime(): FakeDispatchRuntime {
hostedThreadIds.delete(args.threadId);
return { providerCheckpointId: null };
}),
+ applyExtensionAction: vi.fn(async () => ({ applied: true })),
clearThreadGoal: vi.fn(async () => ({ cleared: true })),
renameThread: vi.fn(async () => undefined),
archiveThread: vi.fn(async () => undefined),
@@ -200,6 +201,7 @@ function createRuntime(): FakeDispatchRuntime {
models: [],
selectedOnlyModels: [],
})),
+ listProviderCommands: vi.fn(async () => ({ supported: false as const })),
providerHealth: vi.fn(async () => ({ supported: false as const })),
providerUsage: vi.fn(async () => ({ supported: false as const })),
providerInstallationStatus: vi.fn(async () => {
diff --git a/apps/host-daemon/src/command-dispatch.ts b/apps/host-daemon/src/command-dispatch.ts
index 8242df08a4..df0bccfb1e 100644
--- a/apps/host-daemon/src/command-dispatch.ts
+++ b/apps/host-daemon/src/command-dispatch.ts
@@ -343,6 +343,22 @@ const commandHandlers: CommandHandlerMap = {
release();
}
},
+ "thread.extension-state.action": async (command, options) => {
+ const entry = await options.runtimeManager.getOrAwait(
+ command.environmentId,
+ );
+ if (!entry) {
+ throw new ExpectedCommandDispatchError(
+ "unknown_environment",
+ `No runtime exists for environment ${command.environmentId}`,
+ );
+ }
+ return entry.runtime.applyExtensionAction({
+ threadId: command.threadId,
+ extensionKind: command.extensionKind,
+ action: command.action,
+ });
+ },
"thread.stop": async (command, options) => {
// Release before the target runtime lookup. A moved thread often has no
// runtime in its new environment yet, and the old owner must still stop.
@@ -633,6 +649,19 @@ const onlineRpcHandlers: OnlineRpcHandlerMap = {
bridgeLaunch,
});
},
+ "provider.custom_call": async (command, options) => {
+ const bridgeLaunch = await resolveRuntimeBridgeLaunch(
+ command.bridgeLaunch,
+ options,
+ );
+ const result = await options.providerCustomCall({
+ providerId: command.providerId,
+ bridgeLaunch,
+ method: command.method,
+ input: command.input,
+ });
+ return { result };
+ },
"provider.health": async (command, options) => {
const bridgeLaunch = await resolveRuntimeBridgeLaunch(
command.bridgeLaunch,
diff --git a/apps/host-daemon/src/command-handlers/list-commands.ts b/apps/host-daemon/src/command-handlers/list-commands.ts
index 36961e3d09..8afa127d80 100644
--- a/apps/host-daemon/src/command-handlers/list-commands.ts
+++ b/apps/host-daemon/src/command-handlers/list-commands.ts
@@ -10,6 +10,8 @@ import type {
import type { HostDaemonOnlineRpcResult } from "@bb/host-daemon-contract";
import {
CommandDispatchError,
+ resolveRuntimeBridgeLaunch,
+ type CommandDispatchOptions,
type CommandOf,
} from "../command-dispatch-support.js";
import {
@@ -437,6 +439,7 @@ export async function resolveDeclaredScanRoots(
export async function listHostCommands(
command: CommandOf<"host.list_commands">,
+ options: CommandDispatchOptions,
): Promise> {
if (command.cwd !== null && !path.isAbsolute(command.cwd)) {
throw new CommandDispatchError("invalid_path", "cwd must be absolute");
@@ -448,5 +451,39 @@ export async function listHostCommands(
nativeRoots: command.nativeRoots,
});
const commands = await discoverProviderCommands({ roots });
- return { commands };
+ if (command.cwd === null || command.bridgeLaunch === undefined) {
+ return { commands, diagnostics: [] };
+ }
+
+ // The bridge's own commands are an addition to the scan, never a
+ // condition of it: a bridge that cannot launch or answer leaves the static
+ // commands standing and says why in the diagnostics.
+ let bridgeResult;
+ try {
+ const bridgeLaunch = await resolveRuntimeBridgeLaunch(
+ command.bridgeLaunch,
+ options,
+ );
+ bridgeResult = await options.listProviderCommands({
+ providerId: command.providerId,
+ bridgeLaunch,
+ cwd: command.cwd,
+ });
+ } catch (error) {
+ return {
+ commands,
+ diagnostics: [
+ `${command.providerId} could not list its commands: ${
+ error instanceof Error ? error.message : String(error)
+ }`,
+ ],
+ };
+ }
+ if (!bridgeResult.supported) {
+ return { commands, diagnostics: [] };
+ }
+ return {
+ commands: [...commands, ...bridgeResult.commands],
+ diagnostics: bridgeResult.diagnostics,
+ };
}
diff --git a/apps/host-daemon/src/command-router.ts b/apps/host-daemon/src/command-router.ts
index 4fe66b3091..8fe7da3f87 100644
--- a/apps/host-daemon/src/command-router.ts
+++ b/apps/host-daemon/src/command-router.ts
@@ -72,6 +72,8 @@ export interface CommandRouterOptions {
terminalManager?: CommandDispatchOptions["terminalManager"];
eventSink: CommandDispatchOptions["eventSink"];
listModels: CommandDispatchOptions["listModels"];
+ providerCustomCall: CommandDispatchOptions["providerCustomCall"];
+ listProviderCommands: CommandDispatchOptions["listProviderCommands"];
providerHealth: CommandDispatchOptions["providerHealth"];
providerUsage: CommandDispatchOptions["providerUsage"];
providerInstallationStatus: CommandDispatchOptions["providerInstallationStatus"];
@@ -297,6 +299,8 @@ export class CommandRouter {
dataDir: this.options.dataDir,
eventSink: this.options.eventSink,
listModels: this.options.listModels,
+ providerCustomCall: this.options.providerCustomCall,
+ listProviderCommands: this.options.listProviderCommands,
providerHealth: this.options.providerHealth,
providerUsage: this.options.providerUsage,
providerInstallationStatus: this.options.providerInstallationStatus,
diff --git a/apps/host-daemon/src/interactive-request-registry.test.ts b/apps/host-daemon/src/interactive-request-registry.test.ts
index 660e3a7e8c..2a02b5dfbc 100644
--- a/apps/host-daemon/src/interactive-request-registry.test.ts
+++ b/apps/host-daemon/src/interactive-request-registry.test.ts
@@ -11,6 +11,7 @@ import {
} from "./interactive-request-registry.js";
interface CreateRegistryArgs {
+ onCancellation?: (request: PendingInteractionCreate, reason: string) => void;
registerRequest: (
request: PendingInteractionCreate,
) => Promise;
@@ -54,6 +55,7 @@ function createCommandApprovalResolution(): PendingInteractionResolution {
function createRegistry(args: CreateRegistryArgs): InteractiveRequestRegistry {
return new InteractiveRequestRegistry({
+ onCancellation: args.onCancellation,
registerRequest: args.registerRequest,
});
}
@@ -183,6 +185,43 @@ describe("InteractiveRequestRegistry", () => {
});
});
+ it("cancels a registered request and rejects a late answer", async () => {
+ const request = createCommandApprovalRequest();
+ const cancellations: Array<{
+ request: PendingInteractionCreate;
+ reason: string;
+ }> = [];
+ const registry = createRegistry({
+ onCancellation: (cancelledRequest, reason) =>
+ cancellations.push({ request: cancelledRequest, reason }),
+ registerRequest: async () => ({
+ outcome: "created",
+ interactionId: "pint_registry",
+ status: "pending",
+ }),
+ });
+ const controller = new AbortController();
+ const pending = registry.registerAndWait(request, controller.signal);
+ await Promise.resolve();
+
+ controller.abort(new Error("Pi extensions reloaded"));
+
+ await expect(pending).rejects.toThrow("Pi extensions reloaded");
+ expect(cancellations).toEqual([
+ { request, reason: "Pi extensions reloaded" },
+ ]);
+ expect(() =>
+ registry.resolve({
+ interactionId: "pint_registry",
+ providerId: request.providerId,
+ providerRequestId: request.providerRequestId,
+ providerThreadId: request.providerThreadId,
+ resolution: createCommandApprovalResolution(),
+ threadId: request.threadId,
+ }),
+ ).toThrowError(InteractiveRequestRegistryError);
+ });
+
it("rejects provider waits when the provider exits", async () => {
const request = createCommandApprovalRequest();
const registry = createRegistry({
diff --git a/apps/host-daemon/src/interactive-request-registry.ts b/apps/host-daemon/src/interactive-request-registry.ts
index c353bc710f..7626108e44 100644
--- a/apps/host-daemon/src/interactive-request-registry.ts
+++ b/apps/host-daemon/src/interactive-request-registry.ts
@@ -22,6 +22,7 @@ interface InteractiveRequestRegistrationFailure {
}
interface InteractiveRequestRegistryOptions {
+ onCancellation?: (request: PendingInteractionCreate, reason: string) => void;
onRegistrationFailure?: (
failure: InteractiveRequestRegistrationFailure,
) => void;
@@ -105,6 +106,7 @@ export class InteractiveRequestRegistry {
async registerAndWait(
request: PendingInteractionCreate,
+ signal?: AbortSignal,
): Promise {
const key = buildInteractiveRequestKey(request);
const existing = this.pendingEntries.get(key);
@@ -130,6 +132,28 @@ export class InteractiveRequestRegistry {
request,
};
this.pendingEntries.set(key, entry);
+ const abort = () => {
+ if (this.pendingEntries.get(key) !== entry) {
+ return;
+ }
+ this.pendingEntries.delete(key);
+ const cancellationError =
+ signal?.reason instanceof Error
+ ? signal.reason
+ : new Error("Interactive request cancelled");
+ entry.reject(cancellationError);
+ if (entry.interactionId !== null) {
+ this.options.onCancellation?.(request, cancellationError.message);
+ }
+ };
+ signal?.addEventListener("abort", abort, { once: true });
+ void promise.then(
+ () => signal?.removeEventListener("abort", abort),
+ () => signal?.removeEventListener("abort", abort),
+ );
+ if (signal?.aborted) {
+ abort();
+ }
try {
const response = await this.options.registerRequest(request);
@@ -145,6 +169,15 @@ export class InteractiveRequestRegistry {
}
entry.interactionId = response.interactionId;
+ if (signal?.aborted) {
+ this.options.onCancellation?.(
+ request,
+ signal.reason instanceof Error
+ ? signal.reason.message
+ : "Interactive request cancelled",
+ );
+ return promise;
+ }
if (response.status !== "pending" && response.status !== "resolving") {
this.pendingEntries.delete(key);
entry.reject(
diff --git a/apps/host-daemon/src/runtime-manager.test.ts b/apps/host-daemon/src/runtime-manager.test.ts
index 0651f256b7..fce2711000 100644
--- a/apps/host-daemon/src/runtime-manager.test.ts
+++ b/apps/host-daemon/src/runtime-manager.test.ts
@@ -270,6 +270,7 @@ function createFakeRuntime() {
stopThread: vi.fn(async (_args: StopThreadArgs) => ({
providerCheckpointId: null,
})),
+ applyExtensionAction: vi.fn(async () => ({ applied: true })),
clearThreadGoal: vi.fn(async () => ({ cleared: true })),
renameThread: vi.fn(async (_args: RenameThreadArgs) => undefined),
archiveThread: vi.fn(async () => undefined),
@@ -278,6 +279,7 @@ function createFakeRuntime() {
models: [],
selectedOnlyModels: [],
})),
+ listProviderCommands: vi.fn(async () => ({ supported: false as const })),
providerHealth: vi.fn(async () => ({ supported: false as const })),
providerUsage: vi.fn(async () => ({ supported: false as const })),
providerInstallationStatus: vi.fn(async () => {
diff --git a/apps/host-daemon/src/terminals/terminal-manager.test.ts b/apps/host-daemon/src/terminals/terminal-manager.test.ts
index 21d89ab0cb..157107aeaa 100644
--- a/apps/host-daemon/src/terminals/terminal-manager.test.ts
+++ b/apps/host-daemon/src/terminals/terminal-manager.test.ts
@@ -212,11 +212,13 @@ function createFakeRuntime(): AgentRuntime {
runTurn: vi.fn(async () => undefined),
steerTurn: vi.fn(async () => steerTurnResult),
stopThread: vi.fn(async () => ({ providerCheckpointId: null })),
+ applyExtensionAction: vi.fn(async () => ({ applied: true })),
clearThreadGoal: vi.fn(async () => ({ cleared: true })),
renameThread: vi.fn(async () => undefined),
archiveThread: vi.fn(async () => undefined),
unarchiveThread: vi.fn(async () => undefined),
listModels: vi.fn(async () => ({ models: [], selectedOnlyModels: [] })),
+ listProviderCommands: vi.fn(async () => ({ supported: false as const })),
providerHealth: vi.fn(async () => ({ supported: false as const })),
providerUsage: vi.fn(async () => ({ supported: false as const })),
providerInstallationStatus: vi.fn(async () => {
diff --git a/apps/host-daemon/test/command/dispatch-helpers.ts b/apps/host-daemon/test/command/dispatch-helpers.ts
index 0e4670237e..1d3cfb840c 100644
--- a/apps/host-daemon/test/command/dispatch-helpers.ts
+++ b/apps/host-daemon/test/command/dispatch-helpers.ts
@@ -53,6 +53,8 @@ export const unexpectedProjectAttachmentFetch: FetchProjectAttachment =
export const unexpectedProviderMaintenance: Pick<
CommandDispatchOptions,
| "listModels"
+ | "providerCustomCall"
+ | "listProviderCommands"
| "providerHealth"
| "providerUsage"
| "providerInstallationStatus"
@@ -62,6 +64,12 @@ export const unexpectedProviderMaintenance: Pick<
listModels: async () => {
throw new Error("Unexpected provider.list_models call");
},
+ providerCustomCall: async () => {
+ throw new Error("Unexpected provider.custom_call call");
+ },
+ listProviderCommands: async () => {
+ throw new Error("Unexpected host.list_commands bridge call");
+ },
providerHealth: async () => {
throw new Error("Unexpected provider.health call");
},
@@ -429,6 +437,9 @@ export function createFakeRuntime() {
providerSessionsByThreadId.delete(args.threadId);
return { providerCheckpointId: null };
},
+ async applyExtensionAction() {
+ return { applied: true };
+ },
async clearThreadGoal() {
return { cleared: true };
},
@@ -481,6 +492,9 @@ export function createFakeRuntime() {
selectedOnlyModels: [] satisfies AvailableModel[],
};
},
+ async listProviderCommands() {
+ return { supported: false as const };
+ },
async providerHealth() {
return { supported: false as const };
},
diff --git a/apps/host-daemon/test/command/list-commands-bridge.test.ts b/apps/host-daemon/test/command/list-commands-bridge.test.ts
new file mode 100644
index 0000000000..66652db356
--- /dev/null
+++ b/apps/host-daemon/test/command/list-commands-bridge.test.ts
@@ -0,0 +1,128 @@
+import { EMPTY_PROVIDER_RESOLVED_NATIVE_ROOTS } from "@bb/domain";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { dispatchOnlineRpcCommand } from "../../src/command-dispatch.js";
+import {
+ cleanupTempDirs,
+ createHarness,
+ DISPATCH_TEST_BRIDGE_LAUNCH,
+ makeTempDir,
+} from "./dispatch-helpers.js";
+
+afterEach(cleanupTempDirs);
+
+/**
+ * `host.list_commands` with a bridge launch: the static scan of the
+ * provider's native roots is merged with what the provider's bridge lists
+ * for the cwd (`command/list`), and the bridge's diagnostics ride along.
+ * Without a launch, or without a cwd, or when the bridge does not implement
+ * the method, the static scan stands alone.
+ */
+describe("host.list_commands bridge merge", () => {
+ const emptyRoots = {
+ skills: { user: [], project: [] },
+ commands: { user: [], project: [] },
+ resolved: EMPTY_PROVIDER_RESOLVED_NATIVE_ROOTS,
+ };
+
+ it("merges bridge commands and diagnostics with the static scan", async () => {
+ const cwd = await makeTempDir("bb-list-commands-bridge-");
+ const harness = createHarness({ workspacePath: cwd });
+ const options = harness.dispatchOptions();
+ options.listProviderCommands = vi.fn(async () => ({
+ supported: true as const,
+ commands: [
+ {
+ name: "project-smoke",
+ source: "command" as const,
+ origin: "project" as const,
+ description: "Project smoke command",
+ argumentHint: null,
+ },
+ ],
+ diagnostics: ['Failed to load Pi extension "broken.ts": syntax error'],
+ }));
+
+ const result = await dispatchOnlineRpcCommand(
+ {
+ type: "host.list_commands",
+ providerId: "pi",
+ cwd,
+ nativeRoots: emptyRoots,
+ bridgeLaunch: DISPATCH_TEST_BRIDGE_LAUNCH,
+ },
+ options,
+ );
+
+ expect(result.commands.map((command) => command.name)).toEqual(["project-smoke"]);
+ expect(result.diagnostics).toEqual(['Failed to load Pi extension "broken.ts": syntax error']);
+ expect(options.listProviderCommands).toHaveBeenCalledWith({
+ providerId: "pi",
+ bridgeLaunch: expect.objectContaining({ pluginId: "provider-pi" }),
+ cwd,
+ });
+ });
+
+ it("keeps the static scan and reports a bridge that fails to answer", async () => {
+ const cwd = await makeTempDir("bb-list-commands-failing-");
+ const harness = createHarness({ workspacePath: cwd });
+ const options = harness.dispatchOptions();
+ options.listProviderCommands = vi.fn(async () => {
+ throw new Error("pi exited before its extension reported ready");
+ });
+
+ const result = await dispatchOnlineRpcCommand(
+ {
+ type: "host.list_commands",
+ providerId: "pi",
+ cwd,
+ nativeRoots: emptyRoots,
+ bridgeLaunch: DISPATCH_TEST_BRIDGE_LAUNCH,
+ },
+ options,
+ );
+
+ expect(result.commands).toEqual([]);
+ expect(result.diagnostics).toEqual([
+ "pi could not list its commands: pi exited before its extension reported ready",
+ ]);
+ });
+
+ it("keeps the static scan alone without a launch, without a cwd, or for a bridge without the method", async () => {
+ const cwd = await makeTempDir("bb-list-commands-static-");
+ const harness = createHarness({ workspacePath: cwd });
+ const options = harness.dispatchOptions();
+ const listProviderCommands = vi.fn(async () => ({ supported: false as const }));
+ options.listProviderCommands = listProviderCommands;
+
+ const withoutLaunch = await dispatchOnlineRpcCommand(
+ { type: "host.list_commands", providerId: "pi", cwd, nativeRoots: emptyRoots },
+ options,
+ );
+ expect(withoutLaunch).toEqual({ commands: [], diagnostics: [] });
+ const withoutCwd = await dispatchOnlineRpcCommand(
+ {
+ type: "host.list_commands",
+ providerId: "pi",
+ cwd: null,
+ nativeRoots: emptyRoots,
+ bridgeLaunch: DISPATCH_TEST_BRIDGE_LAUNCH,
+ },
+ options,
+ );
+ expect(withoutCwd).toEqual({ commands: [], diagnostics: [] });
+ expect(listProviderCommands).not.toHaveBeenCalled();
+
+ const unsupported = await dispatchOnlineRpcCommand(
+ {
+ type: "host.list_commands",
+ providerId: "pi",
+ cwd,
+ nativeRoots: emptyRoots,
+ bridgeLaunch: DISPATCH_TEST_BRIDGE_LAUNCH,
+ },
+ options,
+ );
+ expect(unsupported).toEqual({ commands: [], diagnostics: [] });
+ expect(listProviderCommands).toHaveBeenCalledOnce();
+ });
+});
diff --git a/apps/mobile/src/data/interactions/question-form-state.ts b/apps/mobile/src/data/interactions/question-form-state.ts
index a4d5519a93..e3ce1fe0cf 100644
--- a/apps/mobile/src/data/interactions/question-form-state.ts
+++ b/apps/mobile/src/data/interactions/question-form-state.ts
@@ -41,6 +41,9 @@ export interface InteractionFormQuestion {
multiSelect: boolean;
options: readonly InteractionFormOption[];
allowFreeText: boolean;
+ experimental_responseMode?: "verbatim";
+ experimental_placeholder?: string;
+ experimental_prefill?: string;
}
export interface QuestionAnswerState {
@@ -68,6 +71,15 @@ export function normalizeUserQuestion(
: {}),
})),
allowFreeText: question.allowFreeText,
+ ...(question.experimental_responseMode !== undefined
+ ? { experimental_responseMode: question.experimental_responseMode }
+ : {}),
+ ...(question.experimental_placeholder !== undefined
+ ? { experimental_placeholder: question.experimental_placeholder }
+ : {}),
+ ...(question.experimental_prefill !== undefined
+ ? { experimental_prefill: question.experimental_prefill }
+ : {}),
};
}
@@ -114,7 +126,7 @@ function initialAnswerState(
selected: [],
// A question with no options is pure free text — "Other" is implicit.
otherSelected: !questionHasOptions(question),
- otherText: "",
+ otherText: question.experimental_prefill ?? "",
};
}
@@ -147,6 +159,9 @@ export function isQuestionAnswered(
question: InteractionFormQuestion,
state: QuestionAnswerState,
): boolean {
+ if (question.experimental_responseMode === "verbatim") {
+ return state.otherSelected;
+ }
if (validSelectedValues(question, state.selected).length > 0) return true;
return state.otherSelected && state.otherText.trim().length > 0;
}
@@ -199,6 +214,13 @@ function buildQuestionAnswer(
question: InteractionFormQuestion,
state: QuestionAnswerState,
): PendingInteractionUserAnswer {
+ if (question.experimental_responseMode === "verbatim") {
+ return {
+ selected: [],
+ experimental_verbatimText: state.otherText,
+ };
+ }
+
const freeText = state.otherText.trim();
const includeFreeText = state.otherSelected && freeText.length > 0;
if (question.multiSelect) {
diff --git a/apps/mobile/src/data/test/fixtures.ts b/apps/mobile/src/data/test/fixtures.ts
index c14bfd23eb..60e8522193 100644
--- a/apps/mobile/src/data/test/fixtures.ts
+++ b/apps/mobile/src/data/test/fixtures.ts
@@ -154,6 +154,7 @@ export function timelineResponse(
activeThinking: null,
activeWorkflows: [],
activeBackgroundCommands: [],
+ extensionStates: [],
pendingTodos: null,
goal: null,
modelFallback: null,
diff --git a/apps/mobile/src/data/thread-detail/timeline-fetch.test.ts b/apps/mobile/src/data/thread-detail/timeline-fetch.test.ts
index 09767f3de4..dbf8bfe4bc 100644
--- a/apps/mobile/src/data/thread-detail/timeline-fetch.test.ts
+++ b/apps/mobile/src/data/thread-detail/timeline-fetch.test.ts
@@ -33,6 +33,7 @@ function timeline(
activeThinking: null,
activeWorkflows: [],
activeBackgroundCommands: [],
+ extensionStates: [],
pendingTodos: null,
goal: null,
modelFallback: null,
diff --git a/apps/mobile/src/lib/query/realtime-invalidation.ts b/apps/mobile/src/lib/query/realtime-invalidation.ts
index 03ac59becb..9264baecd4 100644
--- a/apps/mobile/src/lib/query/realtime-invalidation.ts
+++ b/apps/mobile/src/lib/query/realtime-invalidation.ts
@@ -314,6 +314,12 @@ export function queryKeysForChangedMessage(
allProjectDefaultExecutionOptionsQueryKeyPrefix(),
);
}
+ if (kinds.has("provider-models-changed")) {
+ keys.push(
+ allSystemExecutionOptionsQueryKeyPrefix(),
+ allProjectDefaultExecutionOptionsQueryKeyPrefix(),
+ );
+ }
if (kinds.has("plugins-changed")) {
// Plugin mention providers / skills come and go with plugins.
keys.push(
diff --git a/apps/mobile/src/screens/thread/interactions/QuestionForm.tsx b/apps/mobile/src/screens/thread/interactions/QuestionForm.tsx
index 972c7126a7..51a7267f16 100644
--- a/apps/mobile/src/screens/thread/interactions/QuestionForm.tsx
+++ b/apps/mobile/src/screens/thread/interactions/QuestionForm.tsx
@@ -220,9 +220,16 @@ function QuestionInputBlock({
accessibilityLabel={`${question.shortLabel} answer`}
value={state.otherText}
editable={!disabled}
- autoCapitalize="sentences"
+ // A verbatim answer goes back byte-for-byte (a Pi extension's
+ // input or editor text); the keyboard must not rewrite it.
+ autoCapitalize={
+ question.experimental_responseMode === "verbatim" ? "none" : "sentences"
+ }
+ autoCorrect={question.experimental_responseMode !== "verbatim"}
onChangeText={onFreeTextChange}
- placeholder="Type your own answer…"
+ placeholder={
+ question.experimental_placeholder ?? "Type your own answer…"
+ }
className="mt-2 max-h-40 bg-surface-raised"
testID="question-free-text"
/>
diff --git a/apps/server/src/internal/extension-payloads.ts b/apps/server/src/internal/extension-payloads.ts
index 363f464a87..25c7d284ed 100644
--- a/apps/server/src/internal/extension-payloads.ts
+++ b/apps/server/src/internal/extension-payloads.ts
@@ -93,7 +93,9 @@ function extensionSiteOf(event: ThreadEvent): ExtensionPayloadSite | null {
}
}
-type ValidationOutcome = { ok: true } | { ok: false; reason: string };
+export type ExtensionValidationOutcome =
+ | { ok: true }
+ | { ok: false; reason: string };
/** A Standard Schema issue path is a bare key or a list of keys/`{ key }`s. */
function issuePathSegments(path: StandardSchemaV1Issue["path"]): string[] {
@@ -113,7 +115,7 @@ function issuePathSegments(path: StandardSchemaV1Issue["path"]): string[] {
async function validateAgainstSchema(
schema: StandardSchemaV1,
payload: JsonValue,
-): Promise {
+): Promise {
let result: StandardSchemaV1Result;
try {
result = await schema["~standard"].validate(payload);
@@ -167,7 +169,7 @@ async function validateSite(
deps: ExtensionPayloadValidationDeps,
site: ExtensionPayloadSite,
providerId: string | null,
-): Promise {
+): Promise {
const ownership = extensionOwnershipProblem(deps, site, providerId);
if (ownership !== null) {
return { ok: false, reason: ownership };
@@ -194,6 +196,39 @@ async function validateSite(
return validateAgainstSchema(schema, site.payload);
}
+export async function validateExtensionAction(
+ deps: ExtensionPayloadValidationDeps,
+ args: {
+ providerId: string;
+ kind: ExtensionKind;
+ action: JsonValue;
+ },
+): Promise {
+ const registration = deps.providerRegistry.get(args.providerId);
+ const { pluginId, name } = parseExtensionKind(args.kind);
+ if (registration === null || registration.pluginId !== pluginId) {
+ return {
+ ok: false,
+ reason: `extension kind ${JSON.stringify(args.kind)} is not owned by provider ${JSON.stringify(args.providerId)}`,
+ };
+ }
+ const schema = registration.extensionKinds[name]?.experimental_action;
+ if (schema === undefined) {
+ return {
+ ok: false,
+ reason: `extension kind ${JSON.stringify(args.kind)} declares no experimental action schema`,
+ };
+ }
+ const bytes = Buffer.byteLength(JSON.stringify(args.action));
+ if (bytes > EXTENSION_PAYLOAD_MAX_BYTES) {
+ return {
+ ok: false,
+ reason: `action is ${bytes} bytes; the limit is ${EXTENSION_PAYLOAD_MAX_BYTES}`,
+ };
+ }
+ return validateAgainstSchema(schema, args.action);
+}
+
/**
* The rejected event's visible replacement. The thread's provider id is what
* `provider/unhandled` is counted under; the kind and payload ride the raw
diff --git a/apps/server/src/internal/interactive-requests.ts b/apps/server/src/internal/interactive-requests.ts
index 93d604e3f0..366d2d7074 100644
--- a/apps/server/src/internal/interactive-requests.ts
+++ b/apps/server/src/internal/interactive-requests.ts
@@ -167,13 +167,15 @@ export function registerInternalInteractiveRequestRoutes(
);
}
- // Daemons must flush provider turn events before every interactive
- // registration attempt. This precondition keeps the server from
- // accepting turn-scoped interaction state before turn/started exists.
- const turnStarted = hasStoredTurnStarted(deps.db, {
- threadId: payload.interaction.threadId,
- turnId: payload.interaction.turnId,
- });
+ // Daemons must flush provider turn events before every turn-scoped
+ // interactive registration attempt. A null turn is an explicitly
+ // thread-scoped extension question and has no turn/started prerequisite.
+ const turnStarted =
+ payload.interaction.turnId === null ||
+ hasStoredTurnStarted(deps.db, {
+ threadId: payload.interaction.threadId,
+ turnId: payload.interaction.turnId,
+ });
if (!turnStarted) {
deps.logger.warn(
{
diff --git a/apps/server/src/routes/projects.ts b/apps/server/src/routes/projects.ts
index c783a42683..c0af59dbb9 100644
--- a/apps/server/src/routes/projects.ts
+++ b/apps/server/src/routes/projects.ts
@@ -88,6 +88,7 @@ import {
providerHasNativeRootSurface,
scanProviderNativeRoots,
} from "../services/providers/native-roots.js";
+import { resolveBridgeLaunchForProviderId } from "../services/system/provider-bridge-launch.js";
import { assertUsableHostId } from "../services/hosts/primary-host.js";
import {
resolveProjectCommandWorkspace,
@@ -700,7 +701,7 @@ export function registerProjectRoutes(app: Hono, deps: AppDeps): void {
// has no typeahead entries: skip every roundtrip.
const registration = deps.providerRegistry.get(query.provider);
if (registration === null || !providerHasCommandSurface(registration)) {
- return context.json({ commands: [] });
+ return context.json({ commands: [], diagnostics: [] });
}
const workspace = resolveProjectCommandWorkspace(deps, {
@@ -716,13 +717,17 @@ export function registerProjectRoutes(app: Hono, deps: AppDeps): void {
// with no roots on either side has nothing for the daemon to scan.
const listProviderCommands = async () => {
if (!providerHasNativeRootSurface(registration)) {
- return { commands: [] };
+ return { commands: [], diagnostics: [] };
}
return scanProviderNativeRoots(deps, {
type: "host.list_commands",
registration,
hostId: workspace.hostId,
cwd: workspace.cwd,
+ // The bridge lists the commands its own resources register (pi's
+ // extension commands); a bridge without the method answers
+ // unsupported and the static scan stands alone.
+ bridgeLaunch: resolveBridgeLaunchForProviderId(deps, query.provider),
});
};
const [result, projectSkillSources, sharedSkills] = await Promise.all([
@@ -745,6 +750,7 @@ export function registerProjectRoutes(app: Hono, deps: AppDeps): void {
return context.json(
buildCommandListResponse({
commands: result.commands,
+ diagnostics: result.diagnostics,
includeBuiltinCompact: deps.providerRegistry.supportsManualCompaction(
query.provider,
),
diff --git a/apps/server/src/routes/threads/actions.ts b/apps/server/src/routes/threads/actions.ts
index 55df5f7779..a33466be5f 100644
--- a/apps/server/src/routes/threads/actions.ts
+++ b/apps/server/src/routes/threads/actions.ts
@@ -31,6 +31,7 @@ import {
} from "@bb/domain";
import type { AppDeps } from "../../types.js";
import { ApiError } from "../../errors.js";
+import { validateExtensionAction } from "../../internal/extension-payloads.js";
import { toThreadQueuedMessage } from "../../services/threads/thread-queued-messages.js";
import {
requestEnvironmentCleanup,
@@ -447,6 +448,35 @@ export function registerThreadActionRoutes(app: Hono, deps: AppDeps): void {
);
});
+ post(routes.experimental_extensionStateAction, async (context, payload) => {
+ const thread = requirePublicThread(deps.db, context.req.param("id"));
+ const validation = await validateExtensionAction(deps, {
+ providerId: thread.providerId,
+ kind: payload.kind,
+ action: payload.action,
+ });
+ if (!validation.ok) {
+ throw new ApiError(400, "invalid_request", validation.reason);
+ }
+ const environment = requireThreadHostCommandEnvironment({
+ db: deps.db,
+ thread,
+ });
+ return context.json(
+ await runLiveHostCommand(deps, {
+ command: {
+ type: "thread.extension-state.action",
+ environmentId: environment.id,
+ threadId: thread.id,
+ extensionKind: payload.kind,
+ action: payload.action,
+ },
+ hostId: environment.hostId,
+ timeoutMs: LIVE_DAEMON_COMMAND_TIMEOUT_MS,
+ }),
+ );
+ });
+
post(routes.compact, async (context) => {
const thread = requirePublicThread(deps.db, context.req.param("id"));
await compactThreadContext(deps, thread);
diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts
index 548deb63f7..985d2938e2 100644
--- a/apps/server/src/server.ts
+++ b/apps/server/src/server.ts
@@ -77,8 +77,11 @@ import {
} from "./browser-request-guard.js";
import {
callPluginHostRpc,
+ callPluginProviderBridgeRpc,
disposePluginHostWorkers,
} from "./services/plugins/plugin-host-rpc.js";
+import { requireBridgeLaunchForProviderId } from "./services/system/provider-bridge-launch.js";
+import { publishProviderModelsChanged } from "./services/system/provider-model-cache.js";
/**
* `/api/v1/plugins//http/...` — the plugin-owned wire, whose auth mode is
@@ -436,6 +439,31 @@ export function createApp(
}),
),
callPluginHost: (args) => callPluginHostRpc(deps, args),
+ callProviderBridge: (args) => {
+ const registration = deps.providerRegistry.get(args.providerId);
+ if (registration === null || registration.pluginId !== args.pluginId) {
+ throw new Error(
+ `plugin "${args.pluginId}" does not own provider "${args.providerId}"`,
+ );
+ }
+ return callPluginProviderBridgeRpc(deps, {
+ ...args,
+ bridgeLaunch: requireBridgeLaunchForProviderId(deps, args.providerId),
+ });
+ },
+ providerModelsChanged: ({ pluginId, providerId, hostId }) => {
+ const registration = deps.providerRegistry.get(providerId);
+ if (registration === null || registration.pluginId !== pluginId) {
+ throw new Error(
+ `plugin "${pluginId}" does not own provider "${providerId}"`,
+ );
+ }
+ publishProviderModelsChanged({
+ providerModelList: deps.lifecycleDedupers.providerModelList,
+ notifySystem: (changes) => deps.hub.notifySystem(changes),
+ hostId,
+ });
+ },
disposePluginHost: (args) => disposePluginHostWorkers(deps, args),
// A plugin resolves its providers' native roots from its settings, so a
// settings save must reach the next listing, not the cached answer.
diff --git a/apps/server/src/services/interactions/pending-interaction-validation.ts b/apps/server/src/services/interactions/pending-interaction-validation.ts
index 864997fee9..c28ee257d1 100644
--- a/apps/server/src/services/interactions/pending-interaction-validation.ts
+++ b/apps/server/src/services/interactions/pending-interaction-validation.ts
@@ -154,7 +154,9 @@ export function pendingInteractionResolutionEquals(
leftAnswer !== undefined &&
rightAnswer !== undefined &&
stringSetEquals(leftAnswer.selected, rightAnswer.selected) &&
- leftAnswer.freeText === rightAnswer.freeText
+ leftAnswer.freeText === rightAnswer.freeText &&
+ leftAnswer.experimental_verbatimText ===
+ rightAnswer.experimental_verbatimText
);
});
}
@@ -443,6 +445,29 @@ function validateUserQuestionResolution(
}
}
+ if (question.experimental_responseMode === "verbatim") {
+ if (
+ answer.experimental_verbatimText === undefined ||
+ answer.freeText !== undefined ||
+ answer.selected.length > 0
+ ) {
+ throw new ApiError(
+ 400,
+ "invalid_request",
+ `Question '${question.id}' requires exactly one verbatim text answer`,
+ );
+ }
+ continue;
+ }
+
+ if (answer.experimental_verbatimText !== undefined) {
+ throw new ApiError(
+ 400,
+ "invalid_request",
+ `Question '${question.id}' does not accept verbatim text answers`,
+ );
+ }
+
if (!question.allowFreeText && answer.freeText !== undefined) {
throw new ApiError(
400,
diff --git a/apps/server/src/services/lib/async-ttl-memo.ts b/apps/server/src/services/lib/async-ttl-memo.ts
index 9477f74678..d664b77a44 100644
--- a/apps/server/src/services/lib/async-ttl-memo.ts
+++ b/apps/server/src/services/lib/async-ttl-memo.ts
@@ -5,6 +5,8 @@
*/
export interface AsyncTtlMemo {
clear(): void;
+ /** Forget the settled and in-flight entries whose key the predicate selects. */
+ deleteWhere(predicate: (key: TKey) => boolean): void;
run(key: TKey, task: () => Promise): Promise;
}
@@ -38,6 +40,14 @@ export function createAsyncTtlMemo({
settledByKey.clear();
pendingByKey.clear();
},
+ deleteWhere(predicate) {
+ for (const key of [...settledByKey.keys()]) {
+ if (predicate(key)) settledByKey.delete(key);
+ }
+ for (const key of [...pendingByKey.keys()]) {
+ if (predicate(key)) pendingByKey.delete(key);
+ }
+ },
run(key, task) {
const currentTime = now();
const settled = settledByKey.get(key);
diff --git a/apps/server/src/services/plugins/plugin-api.ts b/apps/server/src/services/plugins/plugin-api.ts
index 55eac1ab6d..ed9a3a5899 100644
--- a/apps/server/src/services/plugins/plugin-api.ts
+++ b/apps/server/src/services/plugins/plugin-api.ts
@@ -486,6 +486,15 @@ export function createPluginApi(options: {
hostId: string;
signal?: AbortSignal;
}) => Promise;
+ callProviderBridge: (args: {
+ providerId: string;
+ contract: PluginRpcContract;
+ method: string;
+ input: unknown;
+ hostId: string;
+ signal?: AbortSignal;
+ }) => Promise;
+ providerModelsChanged: (args: { providerId: string; hostId: string }) => void;
/** Registers one validated provider declaration with the server's provider
* registry, bound to this plugin's id. Throws on a live id collision. */
registerProvider: (declaration: NormalizedPluginProviderDeclaration) => {
@@ -543,6 +552,8 @@ export function createPluginApi(options: {
declareSharedPorts,
replaceDeclaredSharedPorts,
callPluginHost,
+ callProviderBridge,
+ providerModelsChanged,
registerProvider,
registerAiService,
isProviderIdTaken,
@@ -1408,6 +1419,53 @@ export function createPluginApi(options: {
const providers: PluginProviders = {
register: providerRegistrations.register,
+ experimental_client({ providerId, contract }) {
+ assertLive();
+ return {
+ async call(method, input, callOptions) {
+ assertLive();
+ if (!activated) {
+ throw new Error(
+ "provider bridge calls are unavailable during factory registration; call from a handler, service, or timer",
+ );
+ }
+ if (typeof method !== "string" || contract[method] === undefined) {
+ throw new Error(
+ `unknown provider bridge rpc method "${String(method)}"`,
+ );
+ }
+ if (
+ typeof callOptions !== "object" ||
+ callOptions === null ||
+ typeof callOptions.hostId !== "string" ||
+ callOptions.hostId.length === 0
+ ) {
+ throw new Error(
+ `provider bridge rpc method "${method}" requires a host id`,
+ );
+ }
+ return callProviderBridge({
+ providerId,
+ contract,
+ method,
+ input,
+ hostId: callOptions.hostId,
+ ...(callOptions.signal === undefined
+ ? {}
+ : { signal: callOptions.signal }),
+ });
+ },
+ };
+ },
+ experimental_modelsChanged(args) {
+ assertLive();
+ if (!activated) {
+ throw new Error(
+ "provider model invalidation is unavailable during factory registration",
+ );
+ }
+ providerModelsChanged(args);
+ },
};
/** AI-service registrations, staged like providers; each one binds to the
diff --git a/apps/server/src/services/plugins/plugin-host-rpc.ts b/apps/server/src/services/plugins/plugin-host-rpc.ts
index 5277bc55b8..8388c941db 100644
--- a/apps/server/src/services/plugins/plugin-host-rpc.ts
+++ b/apps/server/src/services/plugins/plugin-host-rpc.ts
@@ -9,6 +9,7 @@ import type { JsonValue } from "@bb/domain";
import { COMMAND_TIMEOUT_MS } from "../../constants.js";
import type { WorkSessionDeps } from "../../types.js";
import { callHostOnlineRpc } from "../hosts/online-rpc.js";
+import type { HostDaemonBridgeLaunch } from "@bb/host-daemon-contract";
import type { PluginHostArtifactSnapshot } from "./plugin-service-internal.js";
const HOST_RPC_TRANSPORT_GRACE_MS = 6_000;
@@ -138,6 +139,61 @@ export async function callPluginHostRpc(
return output;
}
+export async function callPluginProviderBridgeRpc(
+ deps: WorkSessionDeps,
+ args: {
+ contract: PluginRpcContract;
+ method: string;
+ input: unknown;
+ hostId: string;
+ providerId: string;
+ bridgeLaunch: HostDaemonBridgeLaunch;
+ signal?: AbortSignal;
+ },
+): Promise {
+ const method = args.contract[args.method];
+ if (method === undefined) {
+ throw new Error(`unknown provider bridge rpc method "${args.method}"`);
+ }
+ if (args.signal?.aborted) throw abortError();
+ const input = normalizeJson(
+ await validateValue(method.input, args.input, "input"),
+ `provider bridge rpc input for ${args.method}`,
+ );
+ const rpc = callHostOnlineRpc(deps, {
+ hostId: args.hostId,
+ timeoutMs: COMMAND_TIMEOUT_MS + HOST_RPC_TRANSPORT_GRACE_MS,
+ command: {
+ type: "provider.custom_call",
+ providerId: args.providerId,
+ bridgeLaunch: args.bridgeLaunch,
+ method: args.method,
+ input,
+ },
+ });
+ const signal = args.signal;
+ const result =
+ signal === undefined
+ ? await rpc
+ : await new Promise>((resolve, reject) => {
+ let settled = false;
+ const finish = (callback: () => void): void => {
+ if (settled) return;
+ settled = true;
+ signal.removeEventListener("abort", onAbort);
+ callback();
+ };
+ const onAbort = (): void => finish(() => reject(abortError()));
+ signal.addEventListener("abort", onAbort, { once: true });
+ if (signal.aborted) onAbort();
+ rpc.then(
+ (value) => finish(() => resolve(value)),
+ (error) => finish(() => reject(error)),
+ );
+ });
+ return await validateValue(method.output, result.result, "output");
+}
+
export async function disposePluginHostWorkers(
deps: WorkSessionDeps,
args: { pluginId: string; generation: string },
diff --git a/apps/server/src/services/plugins/plugin-runtime.ts b/apps/server/src/services/plugins/plugin-runtime.ts
index 781a06d50c..f8b3204bbd 100644
--- a/apps/server/src/services/plugins/plugin-runtime.ts
+++ b/apps/server/src/services/plugins/plugin-runtime.ts
@@ -1550,6 +1550,18 @@ export function createPluginRuntime(context: PluginRuntimeContext) {
}
deps.sharedPorts?.replaceDeclarationsForOwner(row.id, declarations);
},
+ callProviderBridge: (args) => {
+ if (!deps.callProviderBridge) {
+ throw new Error("provider bridge rpc transport is unavailable");
+ }
+ return deps.callProviderBridge({ pluginId: row.id, ...args });
+ },
+ providerModelsChanged: (args) => {
+ if (!deps.providerModelsChanged) {
+ throw new Error("provider model invalidation is unavailable");
+ }
+ deps.providerModelsChanged({ pluginId: row.id, ...args });
+ },
callPluginHost: (args) => {
if (hostArtifactCandidate === null) {
throw new Error(
diff --git a/apps/server/src/services/plugins/plugin-service-internal.ts b/apps/server/src/services/plugins/plugin-service-internal.ts
index deda418269..95d74282a2 100644
--- a/apps/server/src/services/plugins/plugin-service-internal.ts
+++ b/apps/server/src/services/plugins/plugin-service-internal.ts
@@ -167,6 +167,22 @@ export interface PluginServiceDeps {
timeoutMs?: number;
artifact: PluginHostArtifactSnapshot;
}) => Promise;
+ /** Generic typed provider-bridge RPC transport supplied by the server. */
+ callProviderBridge?: (args: {
+ pluginId: string;
+ providerId: string;
+ contract: import("@get-bb/plugin-sdk").PluginRpcContract;
+ method: string;
+ input: unknown;
+ hostId: string;
+ signal?: AbortSignal;
+ }) => Promise;
+ /** Clears server model memos and notifies clients after a native write. */
+ providerModelsChanged?: (args: {
+ pluginId: string;
+ providerId: string;
+ hostId: string;
+ }) => void;
/** Stops this plugin's workers on connected hosts during reload/disable. */
disposePluginHost?: (args: {
pluginId: string;
diff --git a/apps/server/src/services/providers/native-roots.ts b/apps/server/src/services/providers/native-roots.ts
index d7e61af9e2..deda2d9bf8 100644
--- a/apps/server/src/services/providers/native-roots.ts
+++ b/apps/server/src/services/providers/native-roots.ts
@@ -25,6 +25,7 @@ import {
import type {
HostDaemonOnlineRpcResultForCommand,
HostDaemonRetryableOnlineRpcCommand,
+ HostDaemonBridgeLaunch,
} from "@bb/host-daemon-contract";
import { experimental_nativeRootsHostContract } from "@get-bb/plugin-sdk/host";
import { COMMAND_TIMEOUT_MS } from "../../constants.js";
@@ -311,7 +312,15 @@ interface ScanProviderNativeRootsArgs {
*/
export function scanProviderNativeRoots(
deps: ProviderNativeRootsDeps,
- args: ScanProviderNativeRootsArgs & { type: "host.list_commands" },
+ args: ScanProviderNativeRootsArgs & {
+ type: "host.list_commands";
+ /**
+ * The provider's bridge launch, so the daemon also asks the bridge for
+ * the commands its own resources register (`command/list`) and merges
+ * them with the static scan; null for a provider without a bridge.
+ */
+ bridgeLaunch: HostDaemonBridgeLaunch | null;
+ },
): Promise>;
export function scanProviderNativeRoots(
deps: ProviderNativeRootsDeps,
@@ -319,7 +328,11 @@ export function scanProviderNativeRoots(
): Promise>;
export async function scanProviderNativeRoots(
deps: ProviderNativeRootsDeps,
- args: ScanProviderNativeRootsArgs & { type: ProviderNativeRootScanType },
+ args: ScanProviderNativeRootsArgs &
+ (
+ | { type: "host.list_commands"; bridgeLaunch: HostDaemonBridgeLaunch | null }
+ | { type: "host.list_skills" }
+ ),
): Promise> {
const budget = createProviderListingBudget();
const nativeRoots = await resolveProviderNativeRootSet(deps, {
@@ -338,7 +351,11 @@ export async function scanProviderNativeRoots(
timeoutMs: budget.remainingMs(),
command:
args.type === "host.list_commands"
- ? { type: "host.list_commands", ...scan }
+ ? {
+ type: "host.list_commands",
+ ...scan,
+ ...(args.bridgeLaunch === null ? {} : { bridgeLaunch: args.bridgeLaunch }),
+ }
: { type: "host.list_skills", ...scan },
});
}
diff --git a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md
index 8ef528ef7e..5e30ba438a 100644
--- a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md
+++ b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md
@@ -1308,8 +1308,9 @@ export default definePluginApp((app) => {
});
```
-(The four first-party provider plugins ship no `app.tsx`: bb vendors their
-marks itself, so an icon-only bundle would only add fetches at boot.)
+Most first-party provider plugins ship no `app.tsx`: bb vendors their marks
+itself, so an icon-only bundle would only add fetches at boot. Pi is the
+exception because its app bundle renders Pi extension state.
A provider plugin's `app.tsx` loads in the same deferred boot pass as every
other plugin's, whether or not one of its providers is selected: everything
@@ -1328,6 +1329,28 @@ user reorders them and picks a default in Settings → Providers
(`bb settings general providerOrder '["my-agent","codex"]'` and
`bb settings general defaultProviderId my-agent`).
+A provider plugin with native host-local settings can define a Standard Schema
+contract and call its own bridge without putting provider fields in shared model
+contracts:
+
+```ts
+const bridge = bb.providers.experimental_client({
+ providerId: "echo-agent",
+ contract: nativeSettingsContract,
+});
+const saved = await bridge.call("settings/write", input, { hostId });
+bb.providers.experimental_modelsChanged({
+ providerId: "echo-agent",
+ hostId,
+});
+```
+
+The host id is explicit. Core treats method names and payloads as opaque JSON;
+input and output are validated against `contract`. Call only from handlers,
+services, or timers. After a preference that changes `model/list`, call
+`experimental_modelsChanged` to clear server model memos and refetch client
+pickers. Both APIs are experimental; see `docs/api_to_audit.md`.
+
`experimental_bridgeOptions` must be a plain JSON object no larger than 64
KiB. It is validated and frozen at registration, then carried on every bridge
request as provider-scoped static options. Use it for immutable launch facts
@@ -1883,9 +1906,12 @@ Slot props contracts (versioned, additive-only):
- `homepageSection` → `{ projectId: string | null }` (project in view on
the compose surface). Registration: `{ id, title, component }`.
-- `settingsSection` → `{}` (deliberately no props in V1). Rendered on the
- plugin detail page below the host-rendered declarative settings
- form for running, needs-configuration, and degraded plugins. Registration:
+- `settingsSection` → `{ experimental_hostId?: string | null }`. The
+ experimental host id is BB's selected primary Settings host, or null while
+ none is available; use it for host-local configuration instead of adding a
+ machine picker inside the editor. Rendered on the plugin detail page below
+ the host-rendered declarative settings form for running,
+ needs-configuration, and degraded plugins. Registration:
`{ id, title?, description?, component }`; `title` is an optional host-rendered
section heading and `description` is optional supporting copy rendered with
that heading. Use the existing hooks (`useRpc`, `useRealtime`,
@@ -2221,6 +2247,20 @@ openWorkspaceFile }` — register a leaf
One registration per provider id per plugin; if two plugins claim one
provider id the host keeps the first by plugin id and warns. See the
`app.tsx` example under "The icon" above.
+- `experimental_providerExtensionState` → render one local provider
+ extension-state kind above and below the active thread composer.
+ Registration: `{ name, component }`; `name` is the local `[a-z0-9-]+` name
+ from this plugin's provider declaration, not the namespaced wire kind. The
+ component receives `{ threadId, providerId, kind, payload, sourceSeq,
+placement, experimental_dispatchAction }`. `placement` is `"aboveEditor" |
+"belowEditor"`; return null on the side where the state has nothing to show.
+ The host resolves `/` and never passes another plugin's
+ payload. Validate or narrow `payload` immediately, keep provider-specific
+ rendering here, and use `sourceSeq` only when an effect must distinguish
+ updates with equal payloads. A kind that declares an
+ `experimental_action` Standard Schema can call
+ `experimental_dispatchAction(action)`; actions target only the current
+ session and are never retried automatically.
Host components:
diff --git a/apps/server/src/services/system/event-pruning.ts b/apps/server/src/services/system/event-pruning.ts
index 2f584750e5..e455d45dd7 100644
--- a/apps/server/src/services/system/event-pruning.ts
+++ b/apps/server/src/services/system/event-pruning.ts
@@ -5,6 +5,7 @@ import {
pruneBackgroundTaskProgressEvents,
pruneContextWindowUsageEventsBeforeSequence,
pruneResolvedItemDeltas,
+ pruneSupersededExtensionStateEvents,
pruneTokenUsageEventsBeforeSequence,
pruneThreadEventsBeforeSequence,
} from "@bb/db";
@@ -24,6 +25,7 @@ interface ThreadEventPruningResult {
removedAgePrunableEvents: number;
removedBackgroundTaskProgressEvents: number;
removedResolvedItemDeltas: number;
+ removedSupersededExtensionStates: number;
sequenceCutoff: number;
totalRemoved: number;
}
@@ -44,6 +46,7 @@ type ThreadEventPruningStep =
| "prune_context_window_usage"
| "prune_generic_age_prunable_events"
| "prune_resolved_item_deltas"
+ | "prune_superseded_extension_states"
| "prune_token_usage";
class ThreadEventPruningStepError extends Error {
@@ -79,6 +82,7 @@ const AGE_PRUNABLE_THREAD_EVENT_TYPES: readonly ThreadEventType[] = [
const ACTIVE_PRUNE_TRIGGER_THREAD_EVENT_TYPES: readonly ThreadEventType[] = [
...AGE_PRUNABLE_THREAD_EVENT_TYPES,
"item/backgroundTask/progress",
+ "thread/extensionState/updated",
] as const;
const GENERIC_AGE_PRUNABLE_THREAD_EVENT_TYPES: readonly ThreadEventType[] = [
@@ -158,6 +162,13 @@ export function pruneThreadEventHistory(
types: GENERIC_AGE_PRUNABLE_THREAD_EVENT_TYPES,
}),
);
+ const removedSupersededExtensionStates = runThreadEventPruningStep(
+ "prune_superseded_extension_states",
+ () =>
+ pruneSupersededExtensionStateEvents(deps.db, {
+ threadId: args.threadId,
+ }),
+ );
const removedResolvedItemDeltas = runThreadEventPruningStep(
"prune_resolved_item_deltas",
() =>
@@ -178,11 +189,13 @@ export function pruneThreadEventHistory(
removedAgePrunableEvents,
removedBackgroundTaskProgressEvents,
removedResolvedItemDeltas,
+ removedSupersededExtensionStates,
sequenceCutoff,
totalRemoved:
removedAgePrunableEvents +
removedBackgroundTaskProgressEvents +
- removedResolvedItemDeltas,
+ removedResolvedItemDeltas +
+ removedSupersededExtensionStates,
};
}
diff --git a/apps/server/src/services/system/provider-model-cache.ts b/apps/server/src/services/system/provider-model-cache.ts
new file mode 100644
index 0000000000..31ba44bbec
--- /dev/null
+++ b/apps/server/src/services/system/provider-model-cache.ts
@@ -0,0 +1,24 @@
+import type { SystemChangeKind } from "@bb/domain";
+import type { ProviderModelListMemoValue } from "../../lifecycle-dedupers.js";
+import type { AsyncTtlMemo } from "../lib/async-ttl-memo.js";
+
+/**
+ * A plugin changed a provider's native model preferences on one host: the
+ * catalogs memoized for that host are stale, the other hosts' are not. The
+ * memo is keyed by the JSON array `[hostId, ...]` (execution-options.ts).
+ */
+export function publishProviderModelsChanged(args: {
+ providerModelList: AsyncTtlMemo;
+ notifySystem(changes: SystemChangeKind[]): void;
+ hostId: string;
+}): void {
+ args.providerModelList.deleteWhere((key) => {
+ try {
+ const parsed: unknown = JSON.parse(key);
+ return Array.isArray(parsed) && parsed[0] === args.hostId;
+ } catch {
+ return true;
+ }
+ });
+ args.notifySystem(["provider-models-changed"]);
+}
diff --git a/apps/server/src/services/threads/provider-command-typeahead.ts b/apps/server/src/services/threads/provider-command-typeahead.ts
index ddacb92a18..0cd5b8562b 100644
--- a/apps/server/src/services/threads/provider-command-typeahead.ts
+++ b/apps/server/src/services/threads/provider-command-typeahead.ts
@@ -107,6 +107,7 @@ function compareCommands(a: ProviderCommand, b: ProviderCommand): number {
interface BuildCommandListResponseArgs {
commands: HostProviderCommand[];
+ diagnostics: string[];
includeBuiltinCompact: boolean;
skillCatalog: readonly ResolvedSkillCatalogEntry[];
}
@@ -127,5 +128,6 @@ export function buildCommandListResponse(
...args.skillCatalog.map(toSkillCommand),
...args.commands.map(toProviderCommand),
]).sort(compareCommands),
+ diagnostics: args.diagnostics,
};
}
diff --git a/apps/server/src/services/threads/timeline.ts b/apps/server/src/services/threads/timeline.ts
index af4ed9330a..79e2c76536 100644
--- a/apps/server/src/services/threads/timeline.ts
+++ b/apps/server/src/services/threads/timeline.ts
@@ -41,6 +41,7 @@ import {
listStoredBufferedTextDeltaRowsByItems,
listStoredItemLifecycleRowsByItems,
listLatestBackgroundTaskStateRowsByItemIds,
+ listLatestExtensionStateEventRowsForThread,
listLatestThreadStateEventRowsByThreadIds,
listLatestOpenBackgroundTaskStateRowsForThread,
listStoredTimelineWindowEventRows,
@@ -1003,9 +1004,10 @@ function ensureLatestTimelineOpenBackgroundTaskStateRows(
/**
* Merges the rows that establish head-state banners into the latest window.
*
- * The timeline response carries tail state (`pendingTodos`, `goal`) that
- * describes the head of the thread but is extracted by scanning whatever events
- * the window happens to contain. That is fine when the window reaches the start
+ * The timeline response carries tail state (`pendingTodos`, `goal`, provider
+ * extension state) that describes the head of the thread but is extracted by
+ * scanning whatever events the window happens to contain. That is fine when the
+ * window reaches the start
* of the thread, which is what an unbudgeted window does on the threads where
* this matters — but an event-budgeted window can begin *after* the turn that
* set the goal or wrote the todos, silently dropping the banner mid-session.
@@ -1026,6 +1028,9 @@ function ensureLatestTimelineHeadStateRows(
threadIds: [args.threadId],
kind: LEGACY_CODEX_GOAL_EXTENSION_KIND,
}),
+ ...listLatestExtensionStateEventRowsForThread(db, {
+ threadId: args.threadId,
+ }),
...listTodoSnapshotEventRowsForThread(db, { threadId: args.threadId }),
];
if (headStateRows.length === 0) {
@@ -1785,6 +1790,8 @@ function buildThreadTimelineInternal(
goal: timeline.goal,
modelFallback:
options.page.kind === "latest" ? timeline.modelFallback : null,
+ extensionStates:
+ options.page.kind === "latest" ? timeline.extensionStates : [],
contextWindowUsage:
options.page.kind === "latest"
? (timeline.contextWindowUsage ?? undefined)
@@ -2084,8 +2091,7 @@ export function buildTimelineTurnSummaryDetails(
// route actually holds, so the parent expansion spends what is left rather
// than a pre-closure estimate of it. The subtraction may go negative, which
// is the safe direction: the parent fetch then stays inside its bounds.
- const detailsEventDataBytes =
- byteLengthOfStoredEventRows(wholeItemEventRows);
+ const detailsEventDataBytes = byteLengthOfStoredEventRows(wholeItemEventRows);
const eventRowsWithParentedChildren = ensureTimelineWindowParentedRows(db, {
maxInlineOutputChars: detailsInlineOutputLimit,
outOfBoundsChildDataByteLimit:
diff --git a/apps/server/test/internal/internal-extension-payloads.test.ts b/apps/server/test/internal/internal-extension-payloads.test.ts
index dd0150c15d..6acfcbfa31 100644
--- a/apps/server/test/internal/internal-extension-payloads.test.ts
+++ b/apps/server/test/internal/internal-extension-payloads.test.ts
@@ -14,7 +14,10 @@ import {
} from "@bb/host-daemon-contract";
import { describe, expect, it } from "vitest";
import { z } from "zod";
-import { EXTENSION_PAYLOAD_MAX_BYTES } from "../../src/internal/extension-payloads.js";
+import {
+ EXTENSION_PAYLOAD_MAX_BYTES,
+ validateExtensionAction,
+} from "../../src/internal/extension-payloads.js";
import { buildPluginProviderRegistration } from "../../src/services/providers/plugin-provider-registration.js";
import { validatePluginProviderDeclaration } from "@get-bb/plugin-sdk/internal/host-policy";
import { internalAuthHeaders } from "../helpers/commands.js";
@@ -89,6 +92,7 @@ async function setup() {
goal: {
item: z.object({ objective: z.string().min(1) }),
state: z.object({ status: z.enum(["active", "done"]) }),
+ experimental_action: z.object({ type: z.literal("advance") }),
},
},
});
@@ -361,6 +365,38 @@ describe("extension payload ingest validation", () => {
}
});
+ it("accepts only bounded actions declared by the thread provider", async () => {
+ const { harness } = await setup();
+ try {
+ await expect(
+ validateExtensionAction(harness.deps, {
+ providerId: PROVIDER_ID,
+ kind: GOAL_KIND,
+ action: { type: "advance" },
+ }),
+ ).resolves.toEqual({ ok: true });
+ await expect(
+ validateExtensionAction(harness.deps, {
+ providerId: PROVIDER_ID,
+ kind: GOAL_KIND,
+ action: { type: "other" },
+ }),
+ ).resolves.toMatchObject({ ok: false });
+ await expect(
+ validateExtensionAction(harness.deps, {
+ providerId: PROVIDER_ID,
+ kind: GOAL_KIND,
+ action: {
+ type: "advance",
+ data: "x".repeat(EXTENSION_PAYLOAD_MAX_BYTES),
+ },
+ }),
+ ).resolves.toMatchObject({ ok: false });
+ } finally {
+ await harness.cleanup();
+ }
+ });
+
it("stops accepting a kind once its plugin's registration is disposed", async () => {
const { harness, session, thread } = await setup();
try {
diff --git a/apps/server/test/internal/internal-interactive-requests.test.ts b/apps/server/test/internal/internal-interactive-requests.test.ts
index 061c5f1357..108dc576c8 100644
--- a/apps/server/test/internal/internal-interactive-requests.test.ts
+++ b/apps/server/test/internal/internal-interactive-requests.test.ts
@@ -105,6 +105,9 @@ async function postInteractiveRequest(
function registerInteractiveRequest(
args: RegisterInteractiveRequestArgs,
): Promise {
+ if (args.body.interaction.turnId === null) {
+ throw new Error("Test helper expected a turn-scoped interaction");
+ }
seedTurnStarted(args.harness.deps, {
threadId: args.body.interaction.threadId,
turnId: args.body.interaction.turnId,
@@ -267,6 +270,55 @@ describe("internal interactive request lifecycle", () => {
});
});
+ it("persists an explicitly thread-scoped verbatim question without turn/started", async () => {
+ await withTestHarness(async (harness) => {
+ const { session, thread } = seedThreadFixture(harness, {
+ session: { id: "host-thread-scoped-question" },
+ thread: { providerId: "pi" },
+ });
+
+ const response = await postInteractiveRequest({
+ harness,
+ body: {
+ sessionId: session.id,
+ interaction: {
+ threadId: thread.id,
+ turnId: null,
+ providerId: "pi",
+ providerThreadId: "provider-thread-question",
+ providerRequestId: "request-thread-question",
+ payload: {
+ kind: "user_question",
+ questions: [
+ {
+ id: "value",
+ prompt: "Value",
+ multiSelect: false,
+ allowFreeText: true,
+ experimental_responseMode: "verbatim",
+ },
+ ],
+ },
+ },
+ },
+ });
+
+ expect(response.status).toBe(200);
+ await expect(readJson(response)).resolves.toMatchObject({
+ outcome: "created",
+ status: "pending",
+ });
+ expect(
+ harness.deps.pendingInteractions.listThreadInteractions(thread.id),
+ ).toContainEqual(
+ expect.objectContaining({
+ turnId: null,
+ payload: expect.objectContaining({ kind: "user_question" }),
+ }),
+ );
+ });
+ });
+
it("persists a session-scoped command approval resolution", async () => {
await withTestHarness(async (harness) => {
const { session, environment, thread } = seedThreadFixture(harness, {
diff --git a/apps/server/test/public/public-project-commands.test.ts b/apps/server/test/public/public-project-commands.test.ts
index b14af4158f..b7f52346aa 100644
--- a/apps/server/test/public/public-project-commands.test.ts
+++ b/apps/server/test/public/public-project-commands.test.ts
@@ -9,6 +9,8 @@ import type {
import { commandListResponseSchema } from "@bb/server-contract";
import type { ExperimentalNativeRootsResolveAnswer } from "@get-bb/plugin-sdk/host";
import { describe, expect, it, vi } from "vitest";
+import type { HostDaemonBridgeLaunch } from "@bb/host-daemon-contract";
+import { resolveBridgeLaunchForProviderId } from "../../src/services/system/provider-bridge-launch.js";
import { COMMAND_TIMEOUT_MS } from "../../src/constants.js";
import { registerHostRpcResponder } from "../helpers/host-rpc.js";
import {
@@ -75,6 +77,7 @@ interface RegisterCommandRpcArgs {
hostId: string;
sessionId: string;
commands: HostProviderCommand[];
+ diagnostics?: string[];
skills?: DiscoveredSkill[];
/** What the plugin's `resolveNativeRoots` answers. */
resolved?: ExperimentalNativeRootsResolveAnswer;
@@ -121,7 +124,13 @@ function registerCommandRpc(
}
if (request.command.type === "host.list_commands") {
stub.requests.push(request);
- return { ok: true, result: { commands: stub.commands } };
+ return {
+ ok: true,
+ result: {
+ commands: stub.commands,
+ diagnostics: args.diagnostics ?? [],
+ },
+ };
}
if (request.command.type === "host.list_skills") {
stub.skillRequests.push(request);
@@ -163,6 +172,19 @@ function legacyCommand(
};
}
+
+/**
+ * The launch the route attaches so the daemon can ask the provider's bridge
+ * for its own commands (`command/list`); absent for a provider without one.
+ */
+function expectedBridgeLaunch(
+ deps: Parameters[0],
+ providerId: string,
+): { bridgeLaunch?: HostDaemonBridgeLaunch } {
+ const bridgeLaunch = resolveBridgeLaunchForProviderId(deps, providerId);
+ return bridgeLaunch === null ? {} : { bridgeLaunch };
+}
+
describe("public project command typeahead route", () => {
it("adds configured shared skills to the provider-neutral catalog", async () => {
await withTestHarness(
@@ -263,6 +285,7 @@ describe("public project command typeahead route", () => {
expect(response.status).toBe(200);
expect(stub.requests[0]?.command).toEqual({
+ ...expectedBridgeLaunch(harness.deps, "acp-amp"),
type: "host.list_commands",
providerId: "acp-amp",
cwd: "/tmp/custom-acp-skills",
@@ -341,6 +364,7 @@ describe("public project command typeahead route", () => {
]);
// Its answer rides the daemon command, defaults filled per side.
expect(stub.requests[0]?.command).toEqual({
+ ...expectedBridgeLaunch(harness.deps, "resolving"),
type: "host.list_commands",
providerId: "resolving",
cwd: "/tmp/resolving-project",
@@ -533,6 +557,7 @@ describe("public project command typeahead route", () => {
argumentHint: null,
});
expect(stub.requests[0]?.command).toEqual({
+ ...expectedBridgeLaunch(harness.deps, "codex"),
type: "host.list_commands",
providerId: "codex",
cwd: "/tmp/remote-commands-env",
@@ -626,6 +651,7 @@ describe("public project command typeahead route", () => {
// Exactly one RPC, carrying the requested provider + resolved env cwd.
expect(stub.requests.map((request) => request.command)).toEqual([
{
+ ...expectedBridgeLaunch(harness.deps, "claude-code"),
type: "host.list_commands",
providerId: "claude-code",
cwd: "/tmp/claude-commands-env",
@@ -673,6 +699,7 @@ describe("public project command typeahead route", () => {
"skill-installer",
]);
expect(stub.requests[0]?.command).toEqual({
+ ...expectedBridgeLaunch(harness.deps, "codex"),
type: "host.list_commands",
providerId: "codex",
cwd: "/tmp/codex-commands-env",
@@ -716,6 +743,7 @@ describe("public project command typeahead route", () => {
"stories",
]);
expect(stub.requests[0]?.command).toEqual({
+ ...expectedBridgeLaunch(harness.deps, "codex"),
type: "host.list_commands",
providerId: "codex",
cwd: "/tmp/inherited-skills-project",
@@ -788,13 +816,13 @@ describe("public project command typeahead route", () => {
expect(response.status).toBe(200);
const body = commandListResponseSchema.parse(await readJson(response));
- expect(body).toEqual({ commands: [] });
+ expect(body).toEqual({ commands: [], diagnostics: [] });
// No daemon roundtrip for a provider without a command surface.
expect(stub.requests).toEqual([]);
});
});
- it("lists skills for pi via the shared command surface", async () => {
+ it("returns Pi extension commands and diagnostics beside existing skills", async () => {
await withTestHarness(async (harness) => {
const { host, session } = seedHostSession(harness.deps, {
id: "host-commands-pi",
@@ -810,7 +838,19 @@ describe("public project command typeahead route", () => {
const stub = registerCommandRpc(harness, {
hostId: host.id,
sessionId: session.id,
- commands: [skill("bb-cli", "user", { description: "Use the bb CLI" })],
+ commands: [
+ skill("bb-cli", "user", { description: "Use the bb CLI" }),
+ {
+ name: "project-smoke",
+ source: "command",
+ origin: "project",
+ description: "Project smoke command",
+ argumentHint: null,
+ },
+ ],
+ diagnostics: [
+ 'Failed to load Pi extension "/tmp/pi-commands-env/.pi/extensions/broken.ts": syntax error',
+ ],
});
const response = await harness.app.request(
@@ -822,10 +862,12 @@ describe("public project command typeahead route", () => {
expect(body.commands.map((command) => command.name)).toEqual([
"compact",
"bb-cli",
+ "project-smoke",
]);
// Pi's roots are the plugin's declaration, forwarded as declared
// (the daemon holds no pi skill policy of its own).
expect(stub.requests[0]?.command).toEqual({
+ ...expectedBridgeLaunch(harness.deps, "pi"),
type: "host.list_commands",
providerId: "pi",
cwd: "/tmp/pi-commands-env",
@@ -865,6 +907,7 @@ describe("public project command typeahead route", () => {
// Falls back to the project source path on the primary host, since the
// project has a local-path source even though no environment is given.
expect(stub.requests[0]?.command).toEqual({
+ ...expectedBridgeLaunch(harness.deps, "claude-code"),
type: "host.list_commands",
providerId: "claude-code",
cwd: "/tmp/no-env-project",
@@ -913,6 +956,7 @@ describe("public project command typeahead route", () => {
]);
// Not the provisioning env path; the project source path on the primary host.
expect(stub.requests[0]?.command).toEqual({
+ ...expectedBridgeLaunch(harness.deps, "claude-code"),
type: "host.list_commands",
providerId: "claude-code",
cwd: "/tmp/provisioning-project",
@@ -954,6 +998,7 @@ describe("public project command typeahead route", () => {
"user-only",
]);
expect(stub.requests[0]?.command).toEqual({
+ ...expectedBridgeLaunch(harness.deps, "claude-code"),
type: "host.list_commands",
providerId: "claude-code",
cwd: null,
@@ -988,6 +1033,7 @@ describe("public project command typeahead route", () => {
"home-skill",
]);
expect(stub.requests[0]?.command).toEqual({
+ ...expectedBridgeLaunch(harness.deps, "codex"),
type: "host.list_commands",
providerId: "codex",
cwd: null,
diff --git a/apps/server/test/public/public-project-workspace-routing.test.ts b/apps/server/test/public/public-project-workspace-routing.test.ts
index 14d358b869..56faacedc2 100644
--- a/apps/server/test/public/public-project-workspace-routing.test.ts
+++ b/apps/server/test/public/public-project-workspace-routing.test.ts
@@ -1,6 +1,7 @@
import { createProjectSource } from "@bb/db";
import type { HostProviderCommand } from "@bb/host-daemon-contract";
import { describe, expect, it } from "vitest";
+import { resolveBridgeLaunchForProviderId } from "../../src/services/system/provider-bridge-launch.js";
import { registerHostRpcResponder } from "../helpers/host-rpc.js";
import { declaredNativeRootSet } from "../helpers/provider-registry.js";
import { readJson } from "../helpers/json.js";
@@ -70,7 +71,10 @@ describe("public project workspace routing", () => {
};
}
if (request.command.type === "host.list_commands") {
- return { ok: true, result: { commands: [primaryCommand] } };
+ return {
+ ok: true,
+ result: { commands: [primaryCommand], diagnostics: [] },
+ };
}
if (request.command.type === "plugin.host.call") {
return { ok: true, result: { output: { skills: [], commands: [] } } };
@@ -116,7 +120,10 @@ describe("public project workspace routing", () => {
};
}
if (request.command.type === "host.list_commands") {
- return { ok: true, result: { commands: [remoteCommand] } };
+ return {
+ ok: true,
+ result: { commands: [remoteCommand], diagnostics: [] },
+ };
}
if (request.command.type === "plugin.host.call") {
return { ok: true, result: { output: { skills: [], commands: [] } } };
@@ -190,6 +197,9 @@ describe("public project workspace routing", () => {
(request) => request.command.type === "host.list_commands",
)?.command,
).toEqual({
+ ...(resolveBridgeLaunchForProviderId(harness.deps, "codex") === null
+ ? {}
+ : { bridgeLaunch: resolveBridgeLaunchForProviderId(harness.deps, "codex") }),
type: "host.list_commands",
providerId: "codex",
cwd: "/remote/project",
diff --git a/apps/server/test/public/public-provider-extension-state-action.test.ts b/apps/server/test/public/public-provider-extension-state-action.test.ts
new file mode 100644
index 0000000000..aded88619d
--- /dev/null
+++ b/apps/server/test/public/public-provider-extension-state-action.test.ts
@@ -0,0 +1,133 @@
+import { describe, expect, it } from "vitest";
+import { z } from "zod";
+import { validatePluginProviderDeclaration } from "@get-bb/plugin-sdk/internal/host-policy";
+import { buildPluginProviderRegistration } from "../../src/services/providers/plugin-provider-registration.js";
+import { registerHostRpcResponder } from "../helpers/host-rpc.js";
+import { readJson } from "../helpers/json.js";
+import {
+ seedEnvironment,
+ seedHostSession,
+ seedProjectWithSource,
+ seedThread,
+} from "../helpers/seed.js";
+import { withTestHarness } from "../helpers/test-app.js";
+
+const PLUGIN_ID = "provider-action-test";
+const PROVIDER_ID = "action-test";
+const KIND = `${PLUGIN_ID}/terminal` as const;
+
+function registerProvider(
+ harness: Parameters[0],
+) {
+ harness.deps.providerRegistry.register({
+ ...buildPluginProviderRegistration({
+ available: true,
+ pluginId: PLUGIN_ID,
+ declaration: validatePluginProviderDeclaration({
+ id: PROVIDER_ID,
+ displayName: "Action test",
+ maintenance: { health: false, usage: false, installation: false },
+ capabilities: {
+ supportsServiceTier: false,
+ supportsNativeUserQuestion: false,
+ fork: "none",
+ supportsManualCompaction: false,
+ supportsThreadArchive: false,
+ supportsThreadRename: false,
+ permissionModes: ["full"],
+ reasoningLevels: ["medium"],
+ },
+ composerActions: [],
+ extensionKinds: {
+ terminal: {
+ state: z.unknown(),
+ experimental_action: z.object({ type: z.literal("cancel") }),
+ },
+ },
+ }),
+ readSettings: () => ({}),
+ }),
+ pluginId: PLUGIN_ID,
+ iconNames: new Set(),
+ });
+}
+
+describe("provider extension-state actions", () => {
+ it("validates and forwards an action to the current host runtime", async () => {
+ await withTestHarness(async (harness) => {
+ registerProvider(harness);
+ const { host, session } = seedHostSession(harness.deps);
+ const { project } = seedProjectWithSource(harness.deps, {
+ hostId: host.id,
+ });
+ const environment = seedEnvironment(harness.deps, {
+ hostId: host.id,
+ projectId: project.id,
+ });
+ const thread = seedThread(harness.deps, {
+ environmentId: environment.id,
+ projectId: project.id,
+ providerId: PROVIDER_ID,
+ status: "active",
+ });
+ const responder = registerHostRpcResponder(harness, {
+ hostId: host.id,
+ sessionId: session.id,
+ handle: ({ command }) => {
+ expect(command).toEqual({
+ type: "thread.extension-state.action",
+ environmentId: environment.id,
+ threadId: thread.id,
+ extensionKind: KIND,
+ action: { type: "cancel" },
+ });
+ return { ok: true, result: { applied: true } };
+ },
+ });
+
+ const response = await harness.app.request(
+ `/api/v1/threads/${thread.id}/extension-state/action`,
+ {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ kind: KIND, action: { type: "cancel" } }),
+ },
+ );
+
+ expect(response.status).toBe(200);
+ await expect(readJson(response)).resolves.toEqual({ applied: true });
+ expect(responder.requests).toHaveLength(1);
+ });
+ });
+
+ it("rejects an undeclared action before host dispatch", async () => {
+ await withTestHarness(async (harness) => {
+ registerProvider(harness);
+ const { host } = seedHostSession(harness.deps);
+ const { project } = seedProjectWithSource(harness.deps, {
+ hostId: host.id,
+ });
+ const environment = seedEnvironment(harness.deps, {
+ hostId: host.id,
+ projectId: project.id,
+ });
+ const thread = seedThread(harness.deps, {
+ environmentId: environment.id,
+ projectId: project.id,
+ providerId: PROVIDER_ID,
+ status: "active",
+ });
+
+ const response = await harness.app.request(
+ `/api/v1/threads/${thread.id}/extension-state/action`,
+ {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ kind: KIND, action: { type: "input" } }),
+ },
+ );
+
+ expect(response.status).toBe(400);
+ });
+ });
+});
diff --git a/apps/server/test/public/public-thread-interactions.test.ts b/apps/server/test/public/public-thread-interactions.test.ts
index e450411275..7ae4ab721b 100644
--- a/apps/server/test/public/public-thread-interactions.test.ts
+++ b/apps/server/test/public/public-thread-interactions.test.ts
@@ -48,6 +48,9 @@ function registerPendingInteraction(
lifecycle: PendingInteractionLifecycle,
interaction: PendingInteractionCreate,
) {
+ if (interaction.turnId === null) {
+ throw new Error("Test helper expected a turn-scoped interaction");
+ }
seedTurnStarted(deps, {
threadId: interaction.threadId,
turnId: interaction.turnId,
diff --git a/apps/server/test/services/pending-interactions.test.ts b/apps/server/test/services/pending-interactions.test.ts
index 711241f751..08836365ba 100644
--- a/apps/server/test/services/pending-interactions.test.ts
+++ b/apps/server/test/services/pending-interactions.test.ts
@@ -35,6 +35,9 @@ function registerPendingInteraction(
lifecycle: PendingInteractionLifecycle,
interaction: PendingInteractionCreate,
) {
+ if (interaction.turnId === null) {
+ throw new Error("Test helper expected a turn-scoped interaction");
+ }
seedTurnStarted(deps, {
threadId: interaction.threadId,
turnId: interaction.turnId,
diff --git a/apps/server/test/services/plugins/plugin-authoring-docs.test.ts b/apps/server/test/services/plugins/plugin-authoring-docs.test.ts
index c152962028..a06cf61298 100644
--- a/apps/server/test/services/plugins/plugin-authoring-docs.test.ts
+++ b/apps/server/test/services/plugins/plugin-authoring-docs.test.ts
@@ -5,6 +5,7 @@ import * as pluginSdkApp from "@get-bb/plugin-sdk/app";
import {
type BbPluginApi,
type ExperimentalChangesViewProps,
+ type ExperimentalProviderExtensionStateProps,
type ExperimentalSidebarNavigationProps,
type PluginAppBuilder,
type PluginAppSlots,
@@ -184,6 +185,7 @@ type SlotPropsByName = {
// registration type is the documented surface.
experimental_providerIcon: PluginProviderIconRegistration;
experimental_timelineRenderer: PluginTimelineRendererProps;
+ experimental_providerExtensionState: ExperimentalProviderExtensionStateProps;
};
type MissingSlot = Exclude;
@@ -237,7 +239,7 @@ void _assertAllContentScriptRegistrationFieldsListed;
const FRONTEND_SLOT_PROP_FIELDS = {
homepageSection: ["projectId"],
- settingsSection: [],
+ settingsSection: ["experimental_hostId"],
navPanel: ["subPath"],
threadPanelAction: ["threadId", "params"],
experimental_newThreadPanelAction: ["projectId", "params"],
@@ -300,6 +302,15 @@ const FRONTEND_SLOT_PROP_FIELDS = {
"thread",
"Original",
],
+ experimental_providerExtensionState: [
+ "threadId",
+ "providerId",
+ "kind",
+ "payload",
+ "sourceSeq",
+ "placement",
+ "experimental_dispatchAction",
+ ],
} as const satisfies {
[S in keyof SlotPropsByName]: readonly (keyof SlotPropsByName[S])[];
};
diff --git a/apps/server/test/services/system/provider-model-cache.test.ts b/apps/server/test/services/system/provider-model-cache.test.ts
new file mode 100644
index 0000000000..5d71c244c0
--- /dev/null
+++ b/apps/server/test/services/system/provider-model-cache.test.ts
@@ -0,0 +1,28 @@
+import { describe, expect, it, vi } from "vitest";
+import { createLifecycleDedupers } from "../../../src/lifecycle-dedupers.js";
+import { publishProviderModelsChanged } from "../../../src/services/system/provider-model-cache.js";
+
+const empty = { models: [], selectedOnlyModels: [] };
+
+describe("provider model cache invalidation", () => {
+ it("drops the named host's settled catalogs, keeps the others, and notifies picker clients", async () => {
+ const { providerModelList } = createLifecycleDedupers();
+ const hostOneKey = JSON.stringify(["host-1", "session-1", { providerId: "pi" }]);
+ const hostTwoKey = JSON.stringify(["host-2", "session-2", { providerId: "pi" }]);
+ const firstProbe = vi.fn(async () => empty);
+ await providerModelList.run(hostOneKey, firstProbe);
+ await providerModelList.run(hostOneKey, firstProbe);
+ await providerModelList.run(hostTwoKey, firstProbe);
+ expect(firstProbe).toHaveBeenCalledTimes(2);
+ const notifySystem = vi.fn();
+
+ publishProviderModelsChanged({ providerModelList, notifySystem, hostId: "host-1" });
+
+ const secondProbe = vi.fn(async () => empty);
+ await providerModelList.run(hostOneKey, secondProbe);
+ await providerModelList.run(hostTwoKey, secondProbe);
+ // host-1 was refetched; host-2's catalog was still good.
+ expect(secondProbe).toHaveBeenCalledOnce();
+ expect(notifySystem).toHaveBeenCalledWith(["provider-models-changed"]);
+ });
+});
diff --git a/apps/server/test/services/threads/provider-command-typeahead.test.ts b/apps/server/test/services/threads/provider-command-typeahead.test.ts
index 19df870826..e0dd7bbb61 100644
--- a/apps/server/test/services/threads/provider-command-typeahead.test.ts
+++ b/apps/server/test/services/threads/provider-command-typeahead.test.ts
@@ -27,6 +27,7 @@ describe("buildCommandListResponse", () => {
argumentHint: "",
},
],
+ diagnostics: [],
includeBuiltinCompact: true,
skillCatalog: [],
});
@@ -45,6 +46,7 @@ describe("buildCommandListResponse", () => {
it("includes plugin provenance on canonical skill rows", () => {
const response = buildCommandListResponse({
commands: [],
+ diagnostics: [],
includeBuiltinCompact: true,
skillCatalog: [
{
@@ -77,6 +79,7 @@ describe("buildCommandListResponse", () => {
skill("bb-cli", { description: "Data-dir override" }),
skill("bb-cli", { description: "Built-in default" }),
],
+ diagnostics: [],
includeBuiltinCompact: true,
skillCatalog: [],
});
@@ -97,6 +100,7 @@ describe("buildCommandListResponse", () => {
it("omits the built-in compact row for unsupported providers", () => {
const response = buildCommandListResponse({
commands: [],
+ diagnostics: [],
includeBuiltinCompact: false,
skillCatalog: [],
});
diff --git a/apps/server/test/services/threads/timeline-cache.test.ts b/apps/server/test/services/threads/timeline-cache.test.ts
index 141af64fec..663dd81846 100644
--- a/apps/server/test/services/threads/timeline-cache.test.ts
+++ b/apps/server/test/services/threads/timeline-cache.test.ts
@@ -27,6 +27,7 @@ function makeResponse(rowCount: number): ThreadTimelineResponse {
activeThinking: null,
activeWorkflows: [],
activeBackgroundCommands: [],
+ extensionStates: [],
pendingTodos: null,
goal: null,
modelFallback: null,
diff --git a/apps/server/test/services/threads/timeline-head-state.test.ts b/apps/server/test/services/threads/timeline-head-state.test.ts
index f9f4ee4064..63ceba0d8d 100644
--- a/apps/server/test/services/threads/timeline-head-state.test.ts
+++ b/apps/server/test/services/threads/timeline-head-state.test.ts
@@ -49,7 +49,8 @@ function setup(): { db: DbConnection; thread: Thread } {
}
/**
- * Turn 1 establishes head state (goal, todos, a still-running workflow), then
+ * Turn 1 establishes head state (goal, provider state, todos, a running
+ * workflow), then
* `turns - 1` further turns bury it far above any budgeted window.
*/
function seedThreadWithEarlyHeadState(
@@ -128,6 +129,28 @@ function seedThreadWithEarlyHeadState(
// The plan snapshot is a grammar v3 planSteps item (the bridge folds
// TodoWrite / update_plan into it); the head-state backfill finds it by
// kind through the plan-steps index, never by a tool name.
+ events.push({
+ threadId: thread.id,
+ sequence: (sequence += 1),
+ type: "thread/extensionState/updated",
+ scope: threadScope(),
+ providerThreadId,
+ itemId: null,
+ itemKind: null,
+ parentToolCallId: null,
+ data: JSON.stringify({
+ kind: "provider-test/ui",
+ payload: {
+ terminal: {
+ id: "surface-1",
+ columns: 80,
+ rows: 24,
+ output: "\u001b[32mready\u001b[0m",
+ acceptsInput: true,
+ },
+ },
+ }),
+ });
events.push({
threadId: thread.id,
sequence: (sequence += 1),
@@ -209,7 +232,7 @@ const baseOptions = {
};
describe("timeline head state under a budgeted window", () => {
- it("keeps goal, todos, and a running workflow when the budget excludes the turn that set them", () => {
+ it("keeps provider state, goal, todos, and running work outside the budget", () => {
// Head-state banners describe the head of the thread but are extracted by
// scanning the window. A budgeted window starts well after turn 1 here, so
// without thread-scoped lookups these silently disappear mid-session.
@@ -240,6 +263,21 @@ describe("timeline head state under a budgeted window", () => {
expect(unbudgeted.goal).not.toBeNull();
expect(budgeted.goal).toEqual(unbudgeted.goal);
+ expect(unbudgeted.extensionStates).toContainEqual({
+ kind: "provider-test/ui",
+ payload: {
+ terminal: {
+ id: "surface-1",
+ columns: 80,
+ rows: 24,
+ output: "\u001b[32mready\u001b[0m",
+ acceptsInput: true,
+ },
+ },
+ sourceSeq: expect.any(Number),
+ });
+ expect(budgeted.extensionStates).toEqual(unbudgeted.extensionStates);
+
expect(unbudgeted.activeWorkflows).toHaveLength(1);
expect(budgeted.activeWorkflows).toHaveLength(1);
});
@@ -275,6 +313,7 @@ describe("timeline head state under a budgeted window", () => {
});
expect(budgeted.pendingTodos).toBeNull();
expect(budgeted.goal).toBeNull();
+ expect(budgeted.extensionStates).toEqual([]);
expect(budgeted.activeWorkflows).toHaveLength(0);
});
});
diff --git a/apps/server/test/services/threads/timeline-output-truncation.test.ts b/apps/server/test/services/threads/timeline-output-truncation.test.ts
index 018de78ef7..8f64d03ade 100644
--- a/apps/server/test/services/threads/timeline-output-truncation.test.ts
+++ b/apps/server/test/services/threads/timeline-output-truncation.test.ts
@@ -22,6 +22,7 @@ function response(rows: TimelineRow[]): ThreadTimelineResponse {
activeThinking: null,
activeWorkflows: [],
activeBackgroundCommands: [],
+ extensionStates: [],
pendingTodos: null,
goal: null,
modelFallback: null,
diff --git a/docs/api_to_audit.md b/docs/api_to_audit.md
index 4553baffe5..3fc1fd2c60 100644
--- a/docs/api_to_audit.md
+++ b/docs/api_to_audit.md
@@ -537,6 +537,22 @@ stays, decide whether a bare path is the right shape or whether a plugin
should get named, read-only accessors for the bb-managed files it may read —
a path invites writes into bb's directory, which `bb.storage` exists to
prevent.
+## Thread-scoped provider interactions (`experimental_interactionRequestParamsSchema` / `ExperimentalInteractionRequestParams.experimental_scope`, `BRIDGE_NOTIFICATION_METHODS.experimentalInteractionCancel`, `experimental_interactionCancelNotificationSchema`, `PendingInteractionUserQuestionQuestion.experimental_responseMode`, `PendingInteractionUserQuestionQuestion.experimental_placeholder`, and `PendingInteractionUserAnswer.experimental_verbatimText`)
+
+**What it does.** Lets a provider bridge raise a canonical `user_question`
+outside an active model turn and preserve a single text answer byte-for-byte.
+Pi uses it for extension-command `ctx.ui.select`, `ctx.ui.confirm`, and
+`ctx.ui.input` calls: option values remain internal canonical ids while the Pi
+bridge maps the selected id back to the exact extension option, and verbatim
+text retains whitespace and empty strings.
+
+**Audit before stabilizing.** Confirm which providers need thread-scoped
+questions; confirm per-dialog timeout and signal cancellation should keep using
+the same session-owned cancellation notification; verify option and text bounds
+against every client; and confirm verbatim input remains a question mode rather than earning
+a separate core interaction kind. Recheck that stop, reload, replacement,
+failed startup, and daemon/process loss interrupt persistence and reject late
+responses before removing the prefixes.
## Bridge record mode (`experimental_recordProviderChildIo` and `experimental_isProviderBridgeRecording`)
@@ -577,6 +593,12 @@ whether `retryable` should be per kind (only `sessionArchived` and
`rateLimited` read it today) and whether the runtime should bound the
`rateLimited` ladder from the hint rather than from a constant.
+## Provider bridge command discovery (`BRIDGE_REQUEST_METHODS.experimentalProviderCommandList` and the `experimental_providerCommand*` schemas/types)
+
+**What it does.** Adds the optional, sessionless `command/list` provider-bridge request. It carries an explicit cwd and the provider's static options so the bridge can load the same trusted, project-bound resources as a real session. A supported result returns command metadata and non-fatal diagnostics together: one broken resource must not hide commands from healthy resources. Core currently uses this for Pi extension commands and prompt templates while the daemon keeps its existing static skill scan; Pi's resources load inside the `pi --mode rpc` catalog child the bridge spawns per cwd, never in the bridge process. The only diagnostics a bridge produces today are its own failures to answer (pi not installed, the catalog child failing to start); pi reports a broken extension on its stderr, which the runtime logs.
+
+**Audit before stabilizing.** Confirm which providers need executable command discovery rather than static files, whether providers should declare support before core starts a bridge, whether string diagnostics need structured severity and source fields, whether command argument completion metadata belongs on this boundary, and whether repeated sessionless loads need a provider-owned cache or disposal lifecycle.
+
## Provider maintenance toolkit (`experimental_resolveExecutablePath`, `experimental_readCliVersion`, `experimental_commandOutput`, `experimental_versionFrom`, `experimental_compareVersions`, `experimental_formatCommand`, `experimental_npmCommand`, `experimental_npmGlobalInstallCommand`, `experimental_npmLatestVersion`, `experimental_probeNpmGlobalPackage`, `experimental_npmGlobalInstallSource`, `experimental_installationVerification`, `experimental_downloadedInstallerCommand`, `experimental_clampPercent`) (`@get-bb/plugin-sdk/provider-bridge`)
**What it does.** The host-local probes and install-action plumbing behind a
@@ -726,6 +748,42 @@ malformed runtime targets remain inert in both the app and SDK test runtime.
7. Confirm `PluginFileOpenerSource.experimental_hostId` can become a stable
required `hostId` field without breaking older opener implementations.
+## Provider bridge RPC and model invalidation (`bb.providers.experimental_client` and `bb.providers.experimental_modelsChanged`)
+
+**What it does.** Lets a provider plugin call a typed, provider-owned method on
+its own bridge on an explicit host. Core validates the shared Standard Schema
+contract and JSON transport but does not interpret the method or payload.
+After a native preference write, the owning plugin can clear model-catalog
+memos and broadcast `provider-models-changed` so pickers refetch immediately.
+Pi's global model editor is the first consumer.
+
+**Audit before stabilizing.**
+
+1. Confirm custom bridge methods should remain one fixed `provider/custom`
+ envelope rather than joining the shared provider vocabulary.
+2. Confirm ownership checks, payload limits, timeouts, cancellation behavior,
+ and non-retryable writes against another provider plugin.
+3. Decide whether model invalidation should target one host/provider cache key
+ instead of clearing the small process-wide memo.
+4. Confirm SDK plugin RPC remains the right end-user typed extension point for
+ provider-native settings instead of adding provider fields to core SDK model
+ responses.
+
+## Settings host context (`PluginSettingsSectionProps.experimental_hostId`)
+
+**What it does.** Gives a plugin Settings section BB's selected primary host so
+host-local editors do not add their own machine picker. It is null while no
+host is available and never supplies a cwd or workspace path.
+
+**Audit before stabilizing.**
+
+1. Confirm primary-host selection is sufficient or replace it with one shared
+ Settings-level host picker before stabilizing the prop.
+2. Verify host changes remount or refetch every host-local Settings section and
+ that offline/no-host states remain explicit.
+3. Keep target selection in host chrome; do not let each plugin invent machine
+ selectors inside its editor.
+
## Host plugin foundation (`bb.hosts.experimental_client`, `ExperimentalHostClient.experimental_onWorkerExit`, `ExperimentalHostClient.experimental_onSignal`, `ExperimentalHostRpcContext.experimental_retainWorker`, `experimental_defineHostEntry`, and `experimental_createHostEntryHarness`)
**Kept experimental (2026-08-22).** signals and watches have no consumer (decide whether to delete them or keep them experimental separately from calls), none of the lifetime/limit numbers has been measured against a plugin other than keep-awake, and the artifact-contract names (`experimental_apiVersion`, `experimental_signals`, the injected context members) are read by the daemon from installed artifacts, so renaming them needs a dual-name window plus a protocol bump.
@@ -1021,6 +1079,26 @@ bridge as provider-scoped static options. Core does not interpret its keys.
installed-only provider, and that targeted requests may continue resolving
a registered provider even while discovery says it is absent.
+## Provider extension-state rendering and actions (`app.slots.experimental_providerExtensionState`, `ExperimentalProviderExtensionStateRegistration`, `ExperimentalProviderExtensionStateProps.experimental_dispatchAction`, `PluginProviderExtensionKindDeclaration.experimental_action`, `ThreadsArea.experimental_applyExtensionStateAction`)
+
+**What it does.** A provider plugin registers a renderer for one local state
+kind. The host resolves the persisted namespaced kind through the owning plugin
+id and mounts that renderer above and below the active thread composer with the
+latest validated payload, source sequence, thread, provider, and placement.
+The renderer's `experimental_dispatchAction` callback sends a non-retried JSON
+action only to that same namespaced kind in the thread's current provider
+session; the backend declaration's `experimental_action` Standard Schema
+validates it before dispatch. The host never interprets provider payloads or
+actions; `provider-pi` owns their translation and rendering.
+
+**Audit before stabilizing.** Confirm that the two composer placements cover
+provider state that is not transcript content, that mounting one component at
+both placements is preferable to separate renderer members, and that title and
+editor side effects belong in the renderer rather than a narrower host action
+surface. Audit whether `{ applied: boolean }` is enough action feedback and
+whether every action should remain non-retryable. Confirm mobile's declarative
+fallback before dropping the prefix.
+
## Persistent plugin responsive drawer (`experimental_ResponsiveDrawer`, `ExperimentalResponsiveDrawerProps`)
**What it does.** Gives a plugin the host's persistent, non-modal bottom drawer.
@@ -1033,6 +1111,20 @@ The backdrop and focus stack contain interaction without applying `inert` or
plugins need animation-end or realization callbacks, and whether arbitrary
content-height control should remain a class-name escape hatch.
+## Verbatim interaction editor prefill (`PendingInteractionUserQuestionQuestion.experimental_prefill`)
+
+**What it does.** Seeds the existing verbatim user-question textarea with up to
+4,096 characters. It is accepted only with `experimental_responseMode:
+"verbatim"`; the submitted value continues through
+`experimental_verbatimText` unchanged, including empty strings, leading spaces,
+and newlines. Pi maps `ctx.ui.editor(title, prefill)` onto this existing
+interaction path.
+
+**Audit before stabilizing.** Confirm 4,096 characters is enough for extension
+editors, whether prefill and response limits should be UTF-8 bytes instead of
+JavaScript characters, and whether editor requests eventually deserve a core
+interaction kind distinct from a verbatim question.
+
## `@get-bb/plugin-sdk/provider-bridge` (the provider-bridge authoring surface)
**Kept experimental (2026-08-22).** `experimental_defineProviderBridge` / `experimental_apiVersion` are an artifact↔daemon contract (the bootstrap refuses anything but version 1 by name), and the deprecation window between independently-updating artifacts and daemons (item 4) is undecided.
diff --git a/docs/provider-bridge-protocol.md b/docs/provider-bridge-protocol.md
index 62b46d46a1..ba1476d8f0 100644
--- a/docs/provider-bridge-protocol.md
+++ b/docs/provider-bridge-protocol.md
@@ -64,6 +64,14 @@ Hygiene rules (each traces to incident #853):
- Anything written to stdout that is not protocol traffic is ignored by the
reader; bridges must guard stdout against stray writes.
+A provider plugin may define typed native settings operations behind the
+additive `provider/custom` request. Its payload is `{method, input}` and its
+result is `{result}`; both values are JSON. The owning plugin shares a Standard
+Schema contract between its server entry and bridge, while core treats the
+method and payload as opaque. Calls are host-explicit and ownership-checked.
+This is for provider-native behavior such as Pi's global `enabledModels`, not
+for adding provider fields to shared model or execution contracts.
+
## Versioning and capabilities
`initialize` exchanges `{protocolVersion, capabilities}` in both directions.
@@ -96,6 +104,19 @@ fact may only _narrow_ what the provider's declaration advertises (a
declared fork affordance can turn out unavailable for this agent), never
widen it.
+The optional sessionless `command/list` method loads cwd-bound provider
+resources and returns command metadata plus non-fatal diagnostics. Its explicit
+cwd prevents project resources from leaking across workspaces. Core currently
+uses it for Pi extension commands and prompt templates: the Pi bridge asks the
+`pi --mode rpc` catalog child it keeps per cwd (`get_commands`), so pi loads
+the resources under its own project-trust policy; the daemon retains its
+static skill scan and never imports Pi extensions itself. The diagnostics a
+bridge returns are its own failures to answer (pi not installed, the catalog
+child failing to start); a broken extension is pi's stderr, which the runtime
+logs. Bridges that do not implement the method answer `METHOD_NOT_FOUND`; the
+runtime treats that as unsupported, and the daemon keeps its static scan when
+the bridge errors, with the error as a diagnostic.
+
The sessionless `provider/health`, `provider/usage`,
`provider/installation/status`, and `provider/installation/run` methods are
different: their support is declared by each provider through
@@ -352,6 +373,26 @@ undeclared kind, or a schema miss is persisted as a
`provider/unhandled` in the same batch slot, never dropped and never stored
unvalidated.
+Pi projects its serializable extension UI through one
+`provider-pi/extension-ui` state kind, built from the `extension_ui_request`
+lines pi's RPC mode emits for `ctx.ui.setStatus`, `setWidget`, `notify`,
+`setTitle`, and `setEditorText`. Each snapshot is bounded to 16 statuses, 16
+widgets (32 lines each and 12 KiB combined text), eight notifications, and
+bounded title/editor text; generic ingest adds the 64 KiB payload cap. The
+server replays at most one latest row per kind (32 kinds maximum), and
+maintenance prunes superseded snapshots. Pi emits `null` when a live session
+stops or is replaced, so app reload restores only the current session's state.
+Pi's RPC mode has no `ctx.ui.custom()` (component factories are ignored) and
+no working-message control, so neither appears in the snapshot.
+
+The optional `extension/action` request is the reverse path for interactive
+state. Core carries only `{ threadId, providerThreadId, extensionKind, action }`;
+the provider declaration validates `action` (its `experimental_action` schema)
+before the server sends it, and the bridge translates it. Requests are never
+retried because input may not be idempotent. A kind that declares no action
+schema accepts no actions: the server rejects them before any bridge is asked.
+No first-party provider declares one today.
+
## Identifiers
Three identifier families, three owners:
@@ -369,6 +410,27 @@ provider-native ids as vouched join keys on deltas; the assembler translates
in both directions, so a bridge does zero id translation — including for a
provider that mints its own turn ids (codex).
+## Interaction requests
+
+A bridge asks for user input through the generic `interaction/request` JSON-RPC
+request. Its payload and response are the canonical pending-interaction shapes;
+provider-native dialog protocols do not cross this boundary. By default,
+`turnId: null` means the runtime must resolve the active turn. The experimental
+`experimental_scope: "thread"` form instead means that no turn exists and is
+valid only for a `user_question` payload. Pi uses that form when an extension
+command calls `ctx.ui.select`, `ctx.ui.confirm`, `ctx.ui.input`, or
+`ctx.ui.editor` before Pi accepts the command as model-turn input. Editor
+requests use the existing verbatim user question with
+`experimental_prefill`; the response remains `experimental_verbatimText`, so
+leading spaces, empty lines, and trailing whitespace are not normalized.
+
+Thread-scoped questions are still owned by the live provider session. Stop,
+reload, replacement, failed startup, and process shutdown cancel its outstanding
+requests through the generic `interaction/cancel` notification. The runtime
+aborts the matching host registration, and the host interrupts the persisted
+interaction. Request ids are never reused by Pi, so a late response cannot
+resolve a successor session's question.
+
## Turn lifecycle
State machine per thread, owned by the runtime's assembler, fed by the
@@ -550,7 +612,7 @@ on the same two provider lanes, each message wrapped as
`{ "bbChannel": }`, so a replay can route it back onto the fds.
Layout: `//.ndjson`, with `_process` for lines that
-belong to no thread (`initialize`, `model/list`, provider health, and the
+belong to no thread (`initialize`, `model/list`, `command/list`, provider health, and the
children those spawn). The four directions are `runtime→bridge`,
`bridge→runtime`, `provider→bridge`, and `bridge→provider`. One entry per
line: `{ "ts", "run", "seq", "dir", "line" }`. `seq` is one counter across
diff --git a/docs/provider-plugin-api.md b/docs/provider-plugin-api.md
index e7cc1b43a7..595cddeb58 100644
--- a/docs/provider-plugin-api.md
+++ b/docs/provider-plugin-api.md
@@ -73,7 +73,7 @@ bb.providers.register({
],
serviceTiers: undefined, // optional; open list, model/list is precise
composerActions: ["plan"], // "plan" | "goal"
- extensionKinds: {}, // "": { item?: Schema, state?: Schema }
+ extensionKinds: {}, // "": { item?: Schema, state?: Schema, experimental_action?: Schema }
models: { fallback: [], scope: "host" }, // cold-cache placeholder; scope is
// "host" | "workspace" (default): how far one
// model/list answer travels
diff --git a/packages/agent-runtime/src/bridge-protocol-adapter.test.ts b/packages/agent-runtime/src/bridge-protocol-adapter.test.ts
index f0bc3ab224..6bb4fda015 100644
--- a/packages/agent-runtime/src/bridge-protocol-adapter.test.ts
+++ b/packages/agent-runtime/src/bridge-protocol-adapter.test.ts
@@ -150,6 +150,28 @@ describe("handshake gating", () => {
).toMatchObject({ kind: "noop" });
});
+ it("forwards opaque provider extension actions without interpreting them", () => {
+ const adapter = makeAdapter();
+ expect(
+ adapter.buildCommandPlan({
+ type: "extension/action",
+ threadId: "thr_1",
+ providerThreadId: "p_1",
+ extensionKind: "provider-pi/extension-ui",
+ action: { type: "cancel", surfaceId: "surface-1" },
+ }),
+ ).toEqual({
+ kind: "request",
+ method: "extension/action",
+ params: {
+ threadId: "thr_1",
+ providerThreadId: "p_1",
+ extensionKind: "provider-pi/extension-ui",
+ action: { type: "cancel", surfaceId: "surface-1" },
+ },
+ });
+ });
+
it("moves approval policy ownership per the handshake", () => {
const adapter = makeAdapter();
expect(adapter.approvalEnforcedBy).toBe("runtime");
@@ -157,6 +179,17 @@ describe("handshake gating", () => {
expect(adapter.approvalEnforcedBy).toBe("provider");
});
+ it("routes cwd-bound command discovery with provider context", () => {
+ const adapter = makeAdapter();
+ expect(
+ adapter.buildCommandPlan({ type: "command/list", cwd: "/workspace" }),
+ ).toEqual({
+ kind: "request",
+ method: "command/list",
+ params: { providerId: "fake-bridge", cwd: "/workspace" },
+ });
+ });
+
it("routes declared sessionless maintenance methods with provider context", () => {
const adapter = makeAdapter();
expect(
@@ -511,6 +544,28 @@ describe("inbound request decoding", () => {
).toBeNull();
});
+ it("decodes interaction cancellation notifications", () => {
+ const adapter = makeAdapter();
+
+ expect(
+ adapter.decodeInteractiveCancellation({
+ jsonrpc: "2.0",
+ method: "interaction/cancel",
+ params: {
+ requestId: "pi-interaction-1",
+ providerThreadId: "p_1",
+ threadId: "t_1",
+ reason: "Pi extensions reloaded",
+ },
+ }),
+ ).toEqual({
+ requestId: "pi-interaction-1",
+ providerThreadId: "p_1",
+ threadId: "t_1",
+ reason: "Pi extensions reloaded",
+ });
+ });
+
it("decodes canonical interaction requests with the domain payload", () => {
const adapter = makeAdapter();
const decoded = adapter.decodeInteractiveRequest?.({
diff --git a/packages/agent-runtime/src/bridge-protocol-adapter.ts b/packages/agent-runtime/src/bridge-protocol-adapter.ts
index 4dc478b93c..5b300bdfa8 100644
--- a/packages/agent-runtime/src/bridge-protocol-adapter.ts
+++ b/packages/agent-runtime/src/bridge-protocol-adapter.ts
@@ -20,13 +20,14 @@ import type {
ThreadEvent,
} from "@bb/domain";
import { PROVIDER_FORK_VALUES } from "@bb/domain";
-import { pendingInteractionPayloadSchema } from "@bb/domain";
import {
BRIDGE_INBOUND_REQUEST_METHODS,
BRIDGE_NOTIFICATION_METHODS,
BRIDGE_REQUEST_METHODS,
bridgeCapabilitiesSchema,
initializeResultSchema,
+ experimental_interactionCancelNotificationSchema,
+ interactionRequestParamsSchema,
negotiateGrammarVersion,
PROVIDER_BRIDGE_PROTOCOL_VERSION,
THREAD_DELTA_NOTIFICATION_METHOD,
@@ -44,6 +45,7 @@ import type {
ProviderExecutionContext,
} from "./provider-adapter.js";
import type {
+ DecodedInteractiveCancellation,
DecodedInteractiveRequest,
DecodedToolCallRequest,
ProviderCommandPlan,
@@ -106,6 +108,9 @@ export interface BridgeProtocolAdapter {
decodeInteractiveRequest(
request: ProviderInboundRequest,
): DecodedInteractiveRequest | null;
+ decodeInteractiveCancellation(
+ event: ProviderRuntimeEvent,
+ ): DecodedInteractiveCancellation | null;
buildInteractiveResponse(
args: BuildInteractiveResponseArgs,
): ProviderInteractiveResponse;
@@ -174,19 +179,6 @@ const errorNotificationParamsSchema = z
})
.passthrough();
-const interactionRequestParamsSchema = z.object({
- providerThreadId: z.string().min(1),
- threadId: z.string().min(1).optional(),
- turnId: z.union([z.string().min(1), z.null()]),
- payload: pendingInteractionPayloadSchema,
- /**
- * The request's ids are provider-native (a thread/delta bridge holds no bb
- * ids): translate the turn id and approval-subject item ids through the
- * delta assembler's maps so the app sees the timeline's own ids.
- */
- providerNativeIds: z.boolean().optional(),
-});
-
/** The provider-native-id marker on a normalized tool-call request. */
const providerNativeIdsParamsSchema = z
.object({ providerNativeIds: z.boolean().optional() })
@@ -296,6 +288,24 @@ export function createBridgeProtocolAdapter(
: {}),
},
};
+ case "provider/custom":
+ return {
+ kind: "request",
+ method: BRIDGE_REQUEST_METHODS.experimentalCustomCall,
+ params: { method: command.method, input: command.input },
+ };
+ case "command/list":
+ return {
+ kind: "request",
+ method: BRIDGE_REQUEST_METHODS.experimentalProviderCommandList,
+ params: {
+ providerId: options.id,
+ cwd: command.cwd,
+ ...(options.staticProviderOptions !== undefined
+ ? { providerOptions: options.staticProviderOptions }
+ : {}),
+ },
+ };
case "provider/health":
return {
kind: "request",
@@ -489,6 +499,17 @@ export function createBridgeProtocolAdapter(
),
},
};
+ case "extension/action":
+ return {
+ kind: "request",
+ method: BRIDGE_REQUEST_METHODS.experimentalExtensionAction,
+ params: {
+ threadId: command.threadId,
+ providerThreadId: command.providerThreadId,
+ extensionKind: command.extensionKind,
+ action: command.action,
+ },
+ };
case "thread/stop":
return {
kind: "request",
@@ -810,11 +831,28 @@ export function createBridgeProtocolAdapter(
method: request.method,
providerThreadId: decoded.providerThreadId,
turnId,
+ scope:
+ decoded.experimental_scope === "thread" ? "thread" : "active_turn",
payload,
...(threadId ? { threadId } : {}),
};
},
+ decodeInteractiveCancellation(
+ event: ProviderRuntimeEvent,
+ ): DecodedInteractiveCancellation | null {
+ if (
+ event.method !==
+ BRIDGE_NOTIFICATION_METHODS.experimentalInteractionCancel
+ ) {
+ return null;
+ }
+ const parsed = experimental_interactionCancelNotificationSchema.safeParse(
+ event.params,
+ );
+ return parsed.success ? parsed.data : null;
+ },
+
buildInteractiveResponse(
args: BuildInteractiveResponseArgs,
): ProviderInteractiveResponse {
diff --git a/packages/agent-runtime/src/index.ts b/packages/agent-runtime/src/index.ts
index 70061e13f7..1f7d0525ca 100644
--- a/packages/agent-runtime/src/index.ts
+++ b/packages/agent-runtime/src/index.ts
@@ -10,6 +10,8 @@ export type {
AgentRuntimeSkillRoot,
EnsureProviderArgs,
ListModelsArgs,
+ ListProviderCommandsArgs,
+ ProviderCustomCallArgs,
ReapedIdleProviderSession,
RenameThreadArgs,
ResumeThreadArgs,
diff --git a/packages/agent-runtime/src/permission-matrix.test.ts b/packages/agent-runtime/src/permission-matrix.test.ts
index 0a12c6269a..298e2486aa 100644
--- a/packages/agent-runtime/src/permission-matrix.test.ts
+++ b/packages/agent-runtime/src/permission-matrix.test.ts
@@ -459,6 +459,7 @@ async function runCell(
};
const rawRequest = interactionRequest(requestId, payload);
handleRuntimeProviderRequest({
+ interactiveRequestAbortControllers: new Map(),
getActiveTurnId: () => "turn-1",
getThreadExecutionOptions: () => executionOptions,
onInteractiveRequest,
diff --git a/packages/agent-runtime/src/provider-adapter.ts b/packages/agent-runtime/src/provider-adapter.ts
index 8349638bf3..63d9541508 100644
--- a/packages/agent-runtime/src/provider-adapter.ts
+++ b/packages/agent-runtime/src/provider-adapter.ts
@@ -9,8 +9,10 @@
import type {
ClientTurnRequestId,
DynamicTool,
+ ExtensionKind,
InstructionMode,
JsonObject,
+ JsonValue,
PromptInput,
PromptMode,
ReasoningLevel,
@@ -66,6 +68,8 @@ export type AdapterCommand =
skillRoots: readonly AgentRuntimeSkillRoot[];
}
| { type: "model/list"; cwd?: string }
+ | { type: "provider/custom"; method: string; input: JsonValue }
+ | { type: "command/list"; cwd: string }
| { type: "provider/health"; cwd?: string }
| { type: "provider/usage"; cwd?: string }
| {
@@ -127,6 +131,13 @@ export type AdapterCommand =
clientRequestId: ClientTurnRequestId;
options: ProviderExecutionContext;
}
+ | {
+ type: "extension/action";
+ threadId: string;
+ providerThreadId: string;
+ extensionKind: ExtensionKind;
+ action: JsonValue;
+ }
| {
type: "thread/stop";
threadId: string;
diff --git a/packages/agent-runtime/src/runtime-provider-requests.ts b/packages/agent-runtime/src/runtime-provider-requests.ts
index e999aa4cf5..88a1439935 100644
--- a/packages/agent-runtime/src/runtime-provider-requests.ts
+++ b/packages/agent-runtime/src/runtime-provider-requests.ts
@@ -44,6 +44,7 @@ interface RuntimeProviderRequestArgs {
}
interface HandleRuntimeProviderRequestArgs extends RuntimeProviderRequestArgs {
+ interactiveRequestAbortControllers: Map;
getActiveTurnId: (threadId: string) => string | null;
getThreadExecutionOptions: (
threadId: string,
@@ -80,7 +81,7 @@ type ProviderRequestTurnIdWireValue =
| UnresolvedProviderRequestTurnId
| InvalidProviderRequestTurnId;
-function scopeProviderRequestId(
+export function scopeProviderRequestId(
scope: string,
requestId: string | number,
): string {
@@ -263,13 +264,16 @@ function handleInteractiveProviderRequest(
}
const buildInteractiveResponse =
args.providerProcess.adapter.buildInteractiveResponse;
- const resolvedTurnId = resolveRuntimeProviderRequestTurnId({
- ...args,
- requestKind: "interactive request",
- resolvedThreadId,
- turnId: interactiveReq.turnId,
- });
- if (resolvedTurnId === null) {
+ const resolvedTurnId =
+ interactiveReq.scope === "thread"
+ ? null
+ : resolveRuntimeProviderRequestTurnId({
+ ...args,
+ requestKind: "interactive request",
+ resolvedThreadId,
+ turnId: interactiveReq.turnId,
+ });
+ if (resolvedTurnId === null && interactiveReq.scope !== "thread") {
return true;
}
const resolvedInteractiveReq = {
@@ -346,8 +350,14 @@ function handleInteractiveProviderRequest(
return true;
}
+ const cancellationKey = scopeProviderRequestId(
+ args.providerProcess.interactiveRequestScope,
+ interactiveReq.requestId,
+ );
+ const abortController = new AbortController();
+ args.interactiveRequestAbortControllers.set(cancellationKey, abortController);
void args
- .onInteractiveRequest(scopedInteractiveReq)
+ .onInteractiveRequest(scopedInteractiveReq, abortController.signal)
.then((resolution) => {
const result = buildInteractiveResponse({
request: resolvedInteractiveReq,
@@ -374,6 +384,14 @@ function handleInteractiveProviderRequest(
id: args.parsedId,
message: err instanceof Error ? err.message : String(err),
});
+ })
+ .finally(() => {
+ if (
+ args.interactiveRequestAbortControllers.get(cancellationKey) ===
+ abortController
+ ) {
+ args.interactiveRequestAbortControllers.delete(cancellationKey);
+ }
});
return true;
}
diff --git a/packages/agent-runtime/src/runtime.command-contract.test.ts b/packages/agent-runtime/src/runtime.command-contract.test.ts
index 300f95d402..146c219dd7 100644
--- a/packages/agent-runtime/src/runtime.command-contract.test.ts
+++ b/packages/agent-runtime/src/runtime.command-contract.test.ts
@@ -101,6 +101,154 @@ describe("createAgentRuntime command contracts", () => {
return { record, runtime };
}
+ it("treats command discovery as unsupported when an older bridge lacks the optional method", async () => {
+ const { runtime } = createContractRuntime();
+
+ try {
+ await expect(
+ runtime.listProviderCommands({ providerId: "fake", cwd: tmpDir }),
+ ).resolves.toEqual({ supported: false });
+ } finally {
+ await runtime.shutdown();
+ }
+ });
+
+ it("recreates an idle session with fresh config and the same provider conversation", async () => {
+ const { record, runtime } = createContractRuntime();
+
+ try {
+ const started = await runtime.startThread({
+ environmentId: "env-1",
+ threadId: "t-reload",
+ projectId: "p1",
+ providerId: "fake",
+ options: fullRuntimeOptions,
+ instructions: "stale instructions",
+ });
+ const result = await runtime.reloadThread({
+ environmentId: "env-1",
+ threadId: "t-reload",
+ projectId: "p1",
+ providerId: "fake",
+ providerThreadId: started.providerThreadId,
+ options: { ...fullRuntimeOptions, model: "fresh-model" },
+ instructions: "fresh instructions",
+ });
+
+ expect(result).toEqual({
+ status: "reloaded",
+ providerThreadId: started.providerThreadId,
+ });
+ expect(runtime.getProviderSession("t-reload")).toEqual({
+ providerId: "fake",
+ providerThreadId: started.providerThreadId,
+ });
+ expect(
+ record
+ .read()
+ .filter((entry) =>
+ ["thread/stop", "thread/resume", "turn/start"].includes(
+ entry.method,
+ ),
+ ),
+ ).toEqual([
+ {
+ method: "thread/stop",
+ params: {
+ threadId: "t-reload",
+ providerThreadId: started.providerThreadId,
+ intent: "release",
+ activeTurnId: null,
+ },
+ },
+ expect.objectContaining({
+ method: "thread/resume",
+ params: expect.objectContaining({
+ threadId: "t-reload",
+ providerThreadId: started.providerThreadId,
+ options: expect.objectContaining({
+ instructions: "fresh instructions",
+ model: "fresh-model",
+ }),
+ }),
+ }),
+ ]);
+ } finally {
+ await runtime.shutdown();
+ }
+ });
+
+ it("rejects reload while a turn is active or waiting to start", async () => {
+ const activeEvents: ThreadEvent[] = [];
+ const active = createContractRuntime({
+ onEvent: (event) => activeEvents.push(event),
+ });
+ const pending = createContractRuntime({
+ launch: { scripted: { swallowTurnStart: true } },
+ });
+
+ try {
+ const activeSession = await active.runtime.startThread({
+ environmentId: "env-1",
+ threadId: "t-active-reload",
+ projectId: "p1",
+ providerId: "fake",
+ options: fullRuntimeOptions,
+ });
+ await active.runtime.runTurn({
+ threadId: "t-active-reload",
+ input: [promptTextInput({ text: "delay:1000" })],
+ clientRequestId: "creq_222222224t",
+ options: fullRuntimeOptions,
+ });
+ await waitForThreadTurnStarted({
+ events: activeEvents,
+ runtime: active.runtime,
+ threadId: "t-active-reload",
+ });
+ await expect(
+ active.runtime.reloadThread({
+ environmentId: "env-1",
+ threadId: "t-active-reload",
+ projectId: "p1",
+ providerId: "fake",
+ providerThreadId: activeSession.providerThreadId,
+ options: fullRuntimeOptions,
+ }),
+ ).resolves.toEqual({ status: "rejected", reason: "active-turn" });
+
+ const pendingSession = await pending.runtime.startThread({
+ environmentId: "env-1",
+ threadId: "t-pending-reload",
+ projectId: "p1",
+ providerId: "fake",
+ options: fullRuntimeOptions,
+ });
+ await pending.runtime.runTurn({
+ threadId: "t-pending-reload",
+ input: [promptTextInput({ text: "never starts" })],
+ clientRequestId: "creq_222222224u",
+ options: fullRuntimeOptions,
+ });
+ await expect(
+ pending.runtime.reloadThread({
+ environmentId: "env-1",
+ threadId: "t-pending-reload",
+ projectId: "p1",
+ providerId: "fake",
+ providerThreadId: pendingSession.providerThreadId,
+ options: fullRuntimeOptions,
+ }),
+ ).resolves.toEqual({
+ status: "rejected",
+ reason: "pending-turn-start",
+ });
+ } finally {
+ await active.runtime.shutdown();
+ await pending.runtime.shutdown();
+ }
+ });
+
it("passes runtime workspace-write roots to the provider as provider options", async () => {
const additionalWorkspaceWriteRoots = [
"/repo/.git/worktrees/bb13",
diff --git a/packages/agent-runtime/src/runtime.interactive-requests.test.ts b/packages/agent-runtime/src/runtime.interactive-requests.test.ts
index 7af1ac2e42..84e7772d7b 100644
--- a/packages/agent-runtime/src/runtime.interactive-requests.test.ts
+++ b/packages/agent-runtime/src/runtime.interactive-requests.test.ts
@@ -111,6 +111,7 @@ async function answerDirectRequest(args: {
}
try {
handleRuntimeProviderRequest({
+ interactiveRequestAbortControllers: new Map(),
getActiveTurnId: args.getActiveTurnId ?? (() => "bb-turn-1"),
getThreadExecutionOptions:
args.getThreadExecutionOptions ?? (() => undefined),
@@ -226,6 +227,59 @@ describe("createAgentRuntime interactive requests", () => {
expect(onInteractiveRequest).not.toHaveBeenCalled();
});
+ it("routes an explicitly thread-scoped user question without an active turn", async () => {
+ const onInteractiveRequest = vi.fn(
+ async (): Promise => ({
+ kind: "user_answer",
+ answers: {
+ value: { selected: [], experimental_verbatimText: " exact " },
+ },
+ }),
+ );
+ const answer = await answerDirectRequest({
+ rawRequest: {
+ jsonrpc: "2.0",
+ id: 82,
+ method: "interaction/request",
+ params: {
+ providerThreadId: "prov-1",
+ threadId: "t1",
+ turnId: null,
+ experimental_scope: "thread",
+ payload: {
+ kind: "user_question",
+ questions: [
+ {
+ id: "value",
+ prompt: "Value",
+ multiSelect: false,
+ allowFreeText: true,
+ experimental_responseMode: "verbatim",
+ },
+ ],
+ },
+ },
+ },
+ getActiveTurnId: () => null,
+ onInteractiveRequest,
+ });
+
+ expect(answer).toMatchObject({
+ jsonrpc: "2.0",
+ id: 82,
+ result: {
+ kind: "user_answer",
+ answers: {
+ value: { selected: [], experimental_verbatimText: " exact " },
+ },
+ },
+ });
+ expect(onInteractiveRequest).toHaveBeenCalledWith(
+ expect.objectContaining({ threadId: "t1", turnId: null }),
+ expect.any(AbortSignal),
+ );
+ });
+
it("denies interactive requests when permission escalation is deny", async () => {
const requests: string[] = [];
const events: ThreadEvent[] = [];
@@ -506,6 +560,7 @@ describe("createAgentRuntime interactive requests", () => {
expect.objectContaining({
payload: expect.objectContaining({ kind: "secrets/secret-request" }),
}),
+ expect.any(AbortSignal),
);
expect(answer).toMatchObject({
jsonrpc: "2.0",
diff --git a/packages/agent-runtime/src/runtime.tool-calls.test.ts b/packages/agent-runtime/src/runtime.tool-calls.test.ts
index b576faedda..cb090a0d9f 100644
--- a/packages/agent-runtime/src/runtime.tool-calls.test.ts
+++ b/packages/agent-runtime/src/runtime.tool-calls.test.ts
@@ -207,6 +207,7 @@ describe("createAgentRuntime tool calls", () => {
try {
handleRuntimeProviderRequest({
+ interactiveRequestAbortControllers: new Map(),
getActiveTurnId: () => null,
getThreadExecutionOptions: () => undefined,
onInteractiveRequest: async () => ({
@@ -273,6 +274,7 @@ describe("createAgentRuntime tool calls", () => {
try {
handleRuntimeProviderRequest({
+ interactiveRequestAbortControllers: new Map(),
getActiveTurnId: () => "turn-1",
getThreadExecutionOptions: () => undefined,
onInteractiveRequest: async () => ({
diff --git a/packages/agent-runtime/src/runtime.ts b/packages/agent-runtime/src/runtime.ts
index 769feb760b..0a71c504d5 100644
--- a/packages/agent-runtime/src/runtime.ts
+++ b/packages/agent-runtime/src/runtime.ts
@@ -12,6 +12,9 @@ import type {
import type { AdapterCommand } from "./provider-adapter.js";
import {
BRIDGE_JSON_RPC_ERRORS,
+ experimental_extensionActionResultSchema,
+ experimental_providerCustomCallResultSchema,
+ experimental_providerCommandListResultSchema,
providerHealthResultSchema,
providerInstallationRunResultSchema,
providerInstallationStatusSchema,
@@ -40,6 +43,7 @@ import {
} from "./execution-options.js";
import {
handleRuntimeProviderRequest,
+ scopeProviderRequestId,
type ResolveRuntimeProviderRequestThreadIdArgs,
type RuntimeProviderRequestKind,
} from "./runtime-provider-requests.js";
@@ -323,6 +327,7 @@ export function createAgentRuntime(options: AgentRuntimeOptions): AgentRuntime {
* joins this set — that restart waits for the thread's next turn by design.
*/
const threadsRetryingBridgeRestartOnIdle = new Set();
+ const interactiveRequestAbortControllers = new Map();
const idleProviderSessionSinceMsByThreadId = new Map();
// Accepted turn dispatches awaiting the provider's turn/started. The
// watchdog makes a stalled entry visible instead of silently hung (#1156's
@@ -1422,6 +1427,21 @@ export function createAgentRuntime(options: AgentRuntimeOptions): AgentRuntime {
}
function handleProviderNotification(args: RuntimeParsedMessageArgs): void {
+ const interactiveCancellation =
+ args.proc.adapter.decodeInteractiveCancellation(args.parsed);
+ if (interactiveCancellation) {
+ const key = scopeProviderRequestId(
+ args.proc.interactiveRequestScope,
+ interactiveCancellation.requestId,
+ );
+ const controller = interactiveRequestAbortControllers.get(key);
+ if (controller) {
+ interactiveRequestAbortControllers.delete(key);
+ controller.abort(new Error(interactiveCancellation.reason));
+ }
+ return;
+ }
+
const sourceThreadId = getJsonRpcStringParam(args.parsed, "threadId");
if (
sourceThreadId !== undefined &&
@@ -1481,6 +1501,7 @@ export function createAgentRuntime(options: AgentRuntimeOptions): AgentRuntime {
if (parsedLine.kind === "request") {
handleRuntimeProviderRequest({
+ interactiveRequestAbortControllers,
getActiveTurnId: (threadId) => turnState.getActiveTurnId(threadId),
getThreadExecutionOptions: (threadId) =>
threadRuntimeConfigs.get(threadId)?.options,
@@ -2390,6 +2411,34 @@ export function createAgentRuntime(options: AgentRuntimeOptions): AgentRuntime {
});
},
+ async applyExtensionAction({ threadId, extensionKind, action }) {
+ return runThreadOperation({
+ threadId,
+ work: async () => {
+ const providerId =
+ threadIdentityRegistry.resolveProviderForThread(threadId);
+ const proc = requireProviderProcessForThread(threadId);
+ const command: AdapterCommand = {
+ type: "extension/action",
+ threadId,
+ providerThreadId: requireProviderThreadId(threadId),
+ extensionKind,
+ action,
+ };
+ const plan = requireProviderRequestPlan({
+ commandType: command.type,
+ plan: proc.adapter.buildCommandPlan(command),
+ providerId,
+ });
+ return sendCommand({
+ proc,
+ message: plan,
+ resultSchema: experimental_extensionActionResultSchema,
+ });
+ },
+ });
+ },
+
async clearThreadGoal({ threadId }) {
return runThreadOperation({
threadId,
@@ -2527,6 +2576,57 @@ export function createAgentRuntime(options: AgentRuntimeOptions): AgentRuntime {
return proc.adapter.parseModelListResult(result);
},
+ async providerCustomCall({ providerId, bridgeLaunch, method, input }) {
+ await runtime.ensureProvider({ providerId, bridgeLaunch });
+ const proc = providerProcesses.requireProviderProcess({
+ processKey: resolveProviderProcessKey({ bridgeLaunch, providerId }),
+ providerId,
+ });
+ const command = requireProviderRequestPlan({
+ commandType: "provider/custom",
+ plan: proc.adapter.buildCommandPlan({
+ type: "provider/custom",
+ method,
+ input,
+ }),
+ providerId,
+ });
+ const result = await sendCommand({
+ proc,
+ message: command,
+ resultSchema: experimental_providerCustomCallResultSchema,
+ });
+ return result.result;
+ },
+
+ async listProviderCommands({ providerId, bridgeLaunch, cwd }) {
+ await runtime.ensureProvider({ providerId, bridgeLaunch });
+ const proc = providerProcesses.requireProviderProcess({
+ processKey: resolveProviderProcessKey({ bridgeLaunch, providerId }),
+ providerId,
+ });
+ const plan = requireProviderRequestPlan({
+ commandType: "command/list",
+ plan: proc.adapter.buildCommandPlan({ type: "command/list", cwd }),
+ providerId,
+ });
+ try {
+ return await sendCommand({
+ proc,
+ message: plan,
+ resultSchema: experimental_providerCommandListResultSchema,
+ });
+ } catch (error) {
+ if (
+ error instanceof JsonRpcResponseError &&
+ error.code === BRIDGE_JSON_RPC_ERRORS.METHOD_NOT_FOUND
+ ) {
+ return { supported: false };
+ }
+ throw error;
+ }
+ },
+
async providerHealth({ providerId, bridgeLaunch, cwd }) {
await runtime.ensureProvider({ providerId, bridgeLaunch });
const proc = providerProcesses.requireProviderProcess({
diff --git a/packages/agent-runtime/src/test/runtime-test-harness.ts b/packages/agent-runtime/src/test/runtime-test-harness.ts
index 9140696c90..e20173389a 100644
--- a/packages/agent-runtime/src/test/runtime-test-harness.ts
+++ b/packages/agent-runtime/src/test/runtime-test-harness.ts
@@ -182,6 +182,7 @@ type LaunchBearingMethod =
| "unarchiveThread"
| "reloadThread"
| "listModels"
+ | "listProviderCommands"
| "providerHealth"
| "providerUsage"
| "providerInstallationStatus"
@@ -235,6 +236,8 @@ export function withBridgeLaunch(
runtime.providerInstallationStatus({ bridgeLaunch, ...args }),
providerInstallationRun: (args) =>
runtime.providerInstallationRun({ bridgeLaunch, ...args }),
+ listProviderCommands: (args) =>
+ runtime.listProviderCommands({ bridgeLaunch, ...args }),
};
}
diff --git a/packages/agent-runtime/src/types.ts b/packages/agent-runtime/src/types.ts
index bbf9eff1ef..93117464b9 100644
--- a/packages/agent-runtime/src/types.ts
+++ b/packages/agent-runtime/src/types.ts
@@ -3,8 +3,10 @@ import type {
AvailableModel,
ClientTurnRequestId,
DynamicTool,
+ ExtensionKind,
InstructionMode,
JsonObject,
+ JsonValue,
PendingInteractionCreate,
PendingInteractionResolution,
PromptInput,
@@ -16,6 +18,7 @@ import type {
ToolCallResponse,
} from "@bb/domain";
import type {
+ ExperimentalProviderCommandListResult,
ProviderHealthResult,
ProviderInstallationRunResult,
ProviderInstallationStatus,
@@ -115,6 +118,7 @@ export interface AgentRuntimeOptions {
* The runtime converts provider-native requests into bb's shared pending-interaction contract. */
onInteractiveRequest?: (
request: PendingInteractionCreate,
+ signal?: AbortSignal,
) => Promise;
/** Called on provider stderr lines. */
@@ -310,6 +314,12 @@ interface SteerTurnStaleResult {
export type SteerTurnResult = SteerTurnAppliedResult | SteerTurnStaleResult;
+export interface ApplyExtensionActionArgs {
+ threadId: string;
+ extensionKind: ExtensionKind;
+ action: JsonValue;
+}
+
export interface StopThreadArgs {
threadId: string;
}
@@ -377,6 +387,19 @@ export interface ListModelsArgs {
cwd?: string;
}
+export interface ProviderCustomCallArgs {
+ providerId: string;
+ bridgeLaunch: AgentRuntimeBridgeLaunch;
+ method: string;
+ input: JsonValue;
+}
+
+export interface ListProviderCommandsArgs {
+ providerId: string;
+ bridgeLaunch: AgentRuntimeBridgeLaunch;
+ cwd: string;
+}
+
interface ProviderMaintenanceArgs {
providerId: string;
bridgeLaunch: AgentRuntimeBridgeLaunch;
@@ -422,6 +445,10 @@ export interface AgentRuntime {
*/
stopThread(args: StopThreadArgs): Promise;
+ applyExtensionAction(
+ args: ApplyExtensionActionArgs,
+ ): Promise<{ applied: boolean }>;
+
clearThreadGoal(args: ClearThreadGoalArgs): Promise<{ cleared: boolean }>;
renameThread(args: RenameThreadArgs): Promise;
@@ -435,6 +462,12 @@ export interface AgentRuntime {
selectedOnlyModels: AvailableModel[];
}>;
+ providerCustomCall?(args: ProviderCustomCallArgs): Promise;
+
+ listProviderCommands(
+ args: ListProviderCommandsArgs,
+ ): Promise;
+
providerHealth(
args: ProviderMaintenanceArgs,
): Promise;
diff --git a/packages/client-core/src/timeline/timelineRowSignatures.ts b/packages/client-core/src/timeline/timelineRowSignatures.ts
index 9bedfbfab8..37e0e8e5cb 100644
--- a/packages/client-core/src/timeline/timelineRowSignatures.ts
+++ b/packages/client-core/src/timeline/timelineRowSignatures.ts
@@ -313,6 +313,7 @@ function timelineWorkRowRenderSignature(row: TimelineViewWorkRow): string {
questionId,
answer.selected.join("\u001d"),
answer.freeText,
+ answer.experimental_verbatimText,
]),
)
.join("\u001e")
diff --git a/packages/client-core/test/timeline-merge.test.ts b/packages/client-core/test/timeline-merge.test.ts
index 353da9243a..8be2d393fa 100644
--- a/packages/client-core/test/timeline-merge.test.ts
+++ b/packages/client-core/test/timeline-merge.test.ts
@@ -115,6 +115,7 @@ function makeTimelineResponse(
activeThinking: null,
activeWorkflows: [],
activeBackgroundCommands: [],
+ extensionStates: [],
pendingTodos: null,
goal: null,
modelFallback: null,
diff --git a/packages/db/src/data/events.ts b/packages/db/src/data/events.ts
index 717d823d47..ebe77e165e 100644
--- a/packages/db/src/data/events.ts
+++ b/packages/db/src/data/events.ts
@@ -1109,6 +1109,10 @@ export interface ListLatestThreadStateEventRowsByThreadIdsArgs {
kind: string;
}
+export interface ListLatestExtensionStateEventRowsForThreadArgs {
+ threadId: string;
+}
+
export interface ListOpenTurnInputAcceptedRowsByThreadIdsArgs {
threadIds: readonly string[];
}
@@ -1286,6 +1290,10 @@ export interface PruneBackgroundTaskProgressEventsArgs {
threadId: string;
}
+export interface PruneSupersededExtensionStateEventsArgs {
+ threadId: string;
+}
+
export interface ListOpenBackgroundTaskItemRowsForHostArgs {
hostId: string;
}
@@ -1450,6 +1458,35 @@ export function listLatestThreadStateEventRowsByThreadIds(
});
}
+export function listLatestExtensionStateEventRowsForThread(
+ db: DbQueryConnection,
+ args: ListLatestExtensionStateEventRowsForThreadArgs,
+): StoredEventRow[] {
+ const extensionStateType =
+ "thread/extensionState/updated" satisfies ThreadEventType;
+ return db
+ .select(storedEventRowFields)
+ .from(events)
+ .where(sql`${events}.rowid IN (
+ SELECT latest_extension_state.rowid
+ FROM ${events} AS latest_extension_state
+ WHERE latest_extension_state.thread_id = ${args.threadId}
+ AND latest_extension_state.type = ${extensionStateType}
+ AND latest_extension_state.sequence = (
+ SELECT MAX(candidate.sequence)
+ FROM ${events} AS candidate
+ WHERE candidate.thread_id = latest_extension_state.thread_id
+ AND candidate.type = ${extensionStateType}
+ AND json_extract(candidate.data, '$.kind') =
+ json_extract(latest_extension_state.data, '$.kind')
+ )
+ ORDER BY latest_extension_state.sequence DESC
+ LIMIT 32
+ )`)
+ .orderBy(events.sequence)
+ .all();
+}
+
export function listOpenTurnInputAcceptedRowsByThreadIds(
db: DbQueryConnection,
args: ListOpenTurnInputAcceptedRowsByThreadIdsArgs,
@@ -3562,6 +3599,28 @@ function pruneLatestRowsForContextWindowUsageBeforeSequence(
return result.changes;
}
+export function pruneSupersededExtensionStateEvents(
+ db: DbConnection,
+ args: PruneSupersededExtensionStateEventsArgs,
+): number {
+ const eventType = "thread/extensionState/updated" satisfies ThreadEventType;
+ const result = db.run(sql`
+ DELETE FROM ${events}
+ WHERE ${events.threadId} = ${args.threadId}
+ AND ${events.type} = ${eventType}
+ AND EXISTS (
+ SELECT 1
+ FROM ${events} AS newer
+ WHERE newer.thread_id = ${events.threadId}
+ AND newer.type = ${eventType}
+ AND newer.sequence > ${events.sequence}
+ AND json_extract(newer.data, '$.kind') =
+ json_extract(${events.data}, '$.kind')
+ )
+ `);
+ return result.changes;
+}
+
export function pruneContextWindowUsageEventsBeforeSequence(
db: DbConnection,
args: PruneContextWindowUsageEventsBeforeSequenceArgs,
diff --git a/packages/db/src/data/index.ts b/packages/db/src/data/index.ts
index 4cadc15b1a..e24c66a997 100644
--- a/packages/db/src/data/index.ts
+++ b/packages/db/src/data/index.ts
@@ -279,6 +279,7 @@ export {
listStoredTurnStartedRowsByTurnIdsUpToSequence,
getLatestThreadInterruptedReason,
listLatestThreadStateEventRowsByThreadIds,
+ listLatestExtensionStateEventRowsForThread,
listLatestBackgroundTaskStateRowsByItemIds,
listLatestOpenBackgroundTaskStateRowsForThread,
listTodoSnapshotEventRowsForThread,
@@ -289,6 +290,7 @@ export {
listThreadTurnInterruptionEventStates,
MissingStoredTurnStartedError,
pruneBackgroundTaskProgressEvents,
+ pruneSupersededExtensionStateEvents,
pruneContextWindowUsageEventsBeforeSequence,
pruneTokenUsageEventsBeforeSequence,
pruneResolvedItemDeltas,
diff --git a/packages/db/src/data/pending-interactions.ts b/packages/db/src/data/pending-interactions.ts
index b55509197f..811cfb3dd1 100644
--- a/packages/db/src/data/pending-interactions.ts
+++ b/packages/db/src/data/pending-interactions.ts
@@ -22,7 +22,7 @@ export type CreatePendingInteractionInput =
providerId: string;
providerRequestId: string;
providerThreadId: string;
- turnId: string;
+ turnId: string | null;
})
| (CreatePendingInteractionInputBase & {
originKind: "plugin";
diff --git a/packages/db/test/data/events.test.ts b/packages/db/test/data/events.test.ts
index 1465b3418a..55f26ba2f1 100644
--- a/packages/db/test/data/events.test.ts
+++ b/packages/db/test/data/events.test.ts
@@ -29,6 +29,7 @@ import {
listContextWindowUsageRows,
listCompletedTurnsByThreadIds,
listEvents,
+ listLatestExtensionStateEventRowsForThread,
listLatestThreadStateEventRowsByThreadIds,
listRecentStoredEventRows,
listStoredConversationOutlineEventRows,
@@ -51,6 +52,7 @@ import {
listThreadTurnInterruptionEventStates,
pruneBackgroundTaskProgressEvents,
pruneContextWindowUsageEventsBeforeSequence,
+ pruneSupersededExtensionStateEvents,
pruneTokenUsageEventsBeforeSequence,
pruneResolvedItemDeltas,
pruneThreadEventsBeforeSequence,
@@ -1879,6 +1881,47 @@ describe("events", () => {
expect(rowsByThreadId.get(otherThread.id)?.sequence).toBe(2);
});
+ it("bounds extension-state replay to the latest snapshot of each kind", () => {
+ const { db, thread } = setup();
+ const uiUpdates = Array.from({ length: 40 }, (_, index) => ({
+ threadId: thread.id,
+ sequence: index + 1,
+ type: "thread/extensionState/updated" as const,
+ ...threadEventFields,
+ providerThreadId: "provider-thread-state",
+ data: JSON.stringify({
+ kind: "provider-pi/extension-ui",
+ payload: { revision: index + 1 },
+ }),
+ }));
+ insertEvents(db, noopNotifier, [
+ ...uiUpdates,
+ {
+ threadId: thread.id,
+ sequence: 41,
+ type: "thread/extensionState/updated",
+ ...threadEventFields,
+ providerThreadId: "provider-thread-state",
+ data: JSON.stringify({
+ kind: "provider-pi/other",
+ payload: { revision: 1 },
+ }),
+ },
+ ]);
+
+ expect(
+ listLatestExtensionStateEventRowsForThread(db, {
+ threadId: thread.id,
+ }).map((row) => row.sequence),
+ ).toEqual([40, 41]);
+ expect(
+ pruneSupersededExtensionStateEvents(db, { threadId: thread.id }),
+ ).toBe(39);
+ expect(
+ listEvents(db, { threadId: thread.id }).map((row) => row.sequence),
+ ).toEqual([40, 41]);
+ });
+
it("batches latest goal lookups above the SQLite variable limit", () => {
const { db } = setup();
const threadIds = Array.from(
diff --git a/packages/domain/src/change-kinds.ts b/packages/domain/src/change-kinds.ts
index fdf781b057..8946d8ae8e 100644
--- a/packages/domain/src/change-kinds.ts
+++ b/packages/domain/src/change-kinds.ts
@@ -61,6 +61,7 @@ export const SYSTEM_CHANGE_KINDS = [
"config-changed",
"plugins-changed",
"provider-registrations-changed",
+ "provider-models-changed",
] as const;
export type SystemChangeKind = (typeof SYSTEM_CHANGE_KINDS)[number];
diff --git a/packages/domain/src/pending-interactions.ts b/packages/domain/src/pending-interactions.ts
index 30fee05e3e..c1863a4eac 100644
--- a/packages/domain/src/pending-interactions.ts
+++ b/packages/domain/src/pending-interactions.ts
@@ -216,7 +216,7 @@ export type ApprovalPendingInteractionPayload = z.infer<
>;
export const USER_QUESTION_MAX_QUESTIONS = 4;
-export const USER_QUESTION_MAX_OPTIONS = 4;
+export const USER_QUESTION_MAX_OPTIONS = 100;
export const USER_QUESTION_MAX_SELECTED = 4;
export const USER_QUESTION_MAX_FREE_TEXT_LENGTH = 4096;
@@ -296,8 +296,48 @@ export const pendingInteractionUserQuestionQuestionSchema = z
)
.optional(),
allowFreeText: z.boolean(),
+ /**
+ * Preserve a free-text answer byte-for-byte, including an empty string.
+ * Omission keeps the normal trimmed, non-blank user-question semantics.
+ */
+ experimental_responseMode: z.literal("verbatim").optional(),
+ /** Placeholder for a verbatim text input. */
+ experimental_placeholder: z.string().optional(),
+ /** Initial byte-preserved value for a verbatim multi-line editor. */
+ experimental_prefill: z
+ .string()
+ .max(
+ USER_QUESTION_MAX_FREE_TEXT_LENGTH,
+ `User question prefill cannot exceed ${USER_QUESTION_MAX_FREE_TEXT_LENGTH} characters`,
+ )
+ .optional(),
})
.superRefine((question, context) => {
+ if (
+ question.experimental_responseMode === "verbatim" &&
+ (!question.allowFreeText ||
+ question.multiSelect ||
+ (question.options?.length ?? 0) > 0)
+ ) {
+ context.addIssue({
+ code: z.ZodIssueCode.custom,
+ message:
+ "Verbatim user questions must be single-value free-text questions without options",
+ path: ["experimental_responseMode"],
+ });
+ }
+
+ if (
+ question.experimental_prefill !== undefined &&
+ question.experimental_responseMode !== "verbatim"
+ ) {
+ context.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "Question prefill requires verbatim response semantics",
+ path: ["experimental_prefill"],
+ });
+ }
+
const optionValues = new Set();
question.options?.forEach((option, index) => {
if (optionValues.has(option.value)) {
@@ -476,15 +516,33 @@ export type ApprovalPendingInteractionResolution = z.infer<
typeof approvalPendingInteractionResolutionSchema
>;
-export const pendingInteractionUserAnswerSchema = z.object({
- selected: z
- .array(z.string().min(1))
- .max(
- USER_QUESTION_MAX_SELECTED,
- `User question selected choices cannot exceed ${USER_QUESTION_MAX_SELECTED}`,
- ),
- freeText: pendingInteractionUserQuestionFreeTextSchema.optional(),
-});
+export const pendingInteractionUserAnswerSchema = z
+ .object({
+ selected: z
+ .array(z.string().min(1))
+ .max(
+ USER_QUESTION_MAX_SELECTED,
+ `User question selected choices cannot exceed ${USER_QUESTION_MAX_SELECTED}`,
+ ),
+ freeText: pendingInteractionUserQuestionFreeTextSchema.optional(),
+ /** Exact text returned for a question with verbatim response semantics. */
+ experimental_verbatimText: z
+ .string()
+ .max(
+ USER_QUESTION_MAX_FREE_TEXT_LENGTH,
+ `User question verbatim text cannot exceed ${USER_QUESTION_MAX_FREE_TEXT_LENGTH} characters`,
+ )
+ .optional(),
+ })
+ .refine(
+ (answer) =>
+ answer.freeText === undefined ||
+ answer.experimental_verbatimText === undefined,
+ {
+ message: "User answers cannot contain both free text and verbatim text",
+ path: ["experimental_verbatimText"],
+ },
+ );
export type PendingInteractionUserAnswer = z.infer<
typeof pendingInteractionUserAnswerSchema
>;
@@ -611,14 +669,25 @@ const pendingInteractionPluginOriginSchema = z.object({
rendererId: z.string().min(1),
});
-export const pendingInteractionCreateSchema = z.object({
- threadId: z.string().min(1),
- turnId: z.string().min(1),
- providerId: z.string().min(1),
- providerThreadId: z.string().min(1),
- providerRequestId: z.string().min(1),
- payload: pendingInteractionPayloadSchema,
-});
+export const pendingInteractionCreateSchema = z
+ .object({
+ threadId: z.string().min(1),
+ /** Null only for a user question raised outside a provider turn. */
+ turnId: z.string().min(1).nullable(),
+ providerId: z.string().min(1),
+ providerThreadId: z.string().min(1),
+ providerRequestId: z.string().min(1),
+ payload: pendingInteractionPayloadSchema,
+ })
+ .refine(
+ (interaction) =>
+ interaction.turnId !== null ||
+ interaction.payload.kind === "user_question",
+ {
+ message: "Only user questions may be scoped outside a provider turn",
+ path: ["turnId"],
+ },
+ );
export type PendingInteractionCreate = z.infer<
typeof pendingInteractionCreateSchema
>;
@@ -659,6 +728,11 @@ export type ApprovalPendingInteraction = z.infer<
const userQuestionPendingInteractionSchema =
providerPendingInteractionBaseSchema.extend({
+ /**
+ * A user question may be thread-scoped: a provider extension (Pi's
+ * `ctx.ui.select/confirm/input`) can ask outside any provider turn.
+ */
+ turnId: z.string().min(1).nullable(),
payload: userQuestionPendingInteractionPayloadSchema,
resolution: userQuestionPendingInteractionResolutionSchema.nullable(),
});
diff --git a/packages/domain/src/thread-event-scope.ts b/packages/domain/src/thread-event-scope.ts
index 7d75515075..d01d58415a 100644
--- a/packages/domain/src/thread-event-scope.ts
+++ b/packages/domain/src/thread-event-scope.ts
@@ -209,7 +209,11 @@ const threadEventScopeDefinitionByType = {
"A provider interaction belongs to the turn that raised it; a plugin may raise one outside any turn.",
},
"system/permissionGrant/lifecycle": { policy: "turn" },
- "system/userQuestion/lifecycle": { policy: "turn" },
+ "system/userQuestion/lifecycle": {
+ policy: "thread-or-turn",
+ rationale:
+ "Provider questions normally belong to a model turn; extension-command questions can block before that command opens a turn.",
+ },
"system/thread-provisioning": {
policy: "thread",
rationale:
diff --git a/packages/host-daemon-contract/src/commands.ts b/packages/host-daemon-contract/src/commands.ts
index 991b9b9845..ab3a3bd1ae 100644
--- a/packages/host-daemon-contract/src/commands.ts
+++ b/packages/host-daemon-contract/src/commands.ts
@@ -2,6 +2,7 @@ import {
availableModelSchema,
discoveredWorkspacePropertiesSchema,
dynamicToolSchema,
+ extensionKindSchema,
instructionModeSchema,
pendingInteractionResolutionSchema,
permissionModeSchema,
@@ -392,6 +393,15 @@ export const threadStopCommandSchema = hostDaemonThreadTargetSchema
})
.strict();
+export const threadExtensionStateActionCommandSchema =
+ hostDaemonThreadTargetSchema
+ .extend({
+ type: z.literal("thread.extension-state.action"),
+ extensionKind: extensionKindSchema,
+ action: jsonValueSchema,
+ })
+ .strict();
+
const threadGoalClearCommandSchema = hostDaemonThreadTargetSchema
.extend({
type: z.literal("thread.goal.clear"),
@@ -733,6 +743,12 @@ const hostListCommandsCommandSchema = z
providerId: z.string().min(1),
cwd: z.string().min(1).nullable(),
nativeRoots: providerNativeRootSetSchema,
+ /**
+ * Omission requests static skill/command scanning only. With a launch the
+ * daemon also asks the provider's bridge for its own commands (`command/list`)
+ * and merges them with the static scan.
+ */
+ bridgeLaunch: hostDaemonBridgeLaunchSchema.optional(),
})
.strict();
@@ -950,6 +966,16 @@ const providerListModelsCommandSchema = z.object({
cwd: z.string().min(1).optional(),
});
+const providerCustomCallCommandSchema = z
+ .object({
+ type: z.literal("provider.custom_call"),
+ providerId: z.string().min(1),
+ bridgeLaunch: hostDaemonBridgeLaunchSchema,
+ method: z.string().min(1),
+ input: jsonValueSchema,
+ })
+ .strict();
+
const providerHealthCommandSchema = z
.object({
type: z.literal("provider.health"),
@@ -1346,6 +1372,7 @@ const pluginHostDisposeResultSchema = z
// full raw set across all roots and the server owns de-dup/sort/limit.
const commandListResultSchema = z.object({
commands: z.array(hostProviderCommandSchema),
+ diagnostics: z.array(z.string()),
});
// Like `commandListResultSchema`: the daemon returns the full raw set across
@@ -1413,6 +1440,10 @@ const providerListModelsResultSchema = z.object({
selectedOnlyModels: z.array(availableModelSchema),
});
+const providerCustomCallResultSchema = z
+ .object({ result: jsonValueSchema })
+ .strict();
+
const threadStartResultSchema = z.object({
providerThreadId: z.string().min(1),
});
@@ -1602,6 +1633,15 @@ export const hostDaemonCommandRegistry = {
flushEventsBeforeResult: true,
envLane: null,
}),
+ "thread.extension-state.action": defineHostDaemonCommandDescriptor({
+ type: "thread.extension-state.action",
+ schema: threadExtensionStateActionCommandSchema,
+ resultSchema: z.object({ applied: z.boolean() }).strict(),
+ transport: "settled",
+ retryable: false,
+ flushEventsBeforeResult: true,
+ envLane: null,
+ }),
"thread.goal.clear": defineHostDaemonCommandDescriptor({
type: "thread.goal.clear",
schema: threadGoalClearCommandSchema,
@@ -1971,6 +2011,15 @@ export const hostDaemonCommandRegistry = {
flushEventsBeforeResult: false,
envLane: null,
}),
+ "provider.custom_call": defineHostDaemonCommandDescriptor({
+ type: "provider.custom_call",
+ schema: providerCustomCallCommandSchema,
+ resultSchema: providerCustomCallResultSchema,
+ transport: "onlineRpc",
+ retryable: false,
+ flushEventsBeforeResult: false,
+ envLane: null,
+ }),
"provider.health": defineHostDaemonCommandDescriptor({
type: "provider.health",
schema: providerHealthCommandSchema,
diff --git a/packages/host-daemon-contract/src/protocol.ts b/packages/host-daemon-contract/src/protocol.ts
index 0f08447c5d..5eb7f7a348 100644
--- a/packages/host-daemon-contract/src/protocol.ts
+++ b/packages/host-daemon-contract/src/protocol.ts
@@ -333,12 +333,24 @@
//
// The version mismatch is what triggers the enrolled daemon's automatic update
// instead of an `invalid-message` reconnect loop.
-export const HOST_DAEMON_PROTOCOL_VERSION = 171 as const;
+export const HOST_DAEMON_PROTOCOL_VERSION = 172 as const;
// Version 171 adds `thread.reload` (server → daemon): recreate an idle
// thread's provider session from the thread's current config, answered with
// the provider thread id. An older daemon rejects the unknown command, so the
// bump is what moves an enrolled machine forward.
+// Version 172 carries provider extension UI over the wire. Server → daemon:
+// `provider.custom_call` (a plugin's own bridge RPC on a host),
+// `thread.extension-state.action` (an app action for a provider's extension
+// state), `host.list_commands` with an optional `bridgeLaunch` so the daemon
+// also asks the provider's bridge for its own commands (`command/list`) and
+// returns the required `diagnostics` list beside the scan, and
+// `interactive.resolve` resolutions that may carry
+// `experimental_verbatimText`. Daemon → server: an interactive-request
+// registration may carry `turnId: null` for a user question a provider raised
+// outside any turn, and persisted `extension.state` snapshots ride the
+// thread-delta path. An older daemon rejects the unknown commands and the
+// launch field and returns no diagnostics list.
/**
* Absolute ceiling for any executable artifact delivered to a host daemon —
diff --git a/packages/host-daemon-contract/src/session.ts b/packages/host-daemon-contract/src/session.ts
index 6dc41ac729..ea4a51eaa2 100644
--- a/packages/host-daemon-contract/src/session.ts
+++ b/packages/host-daemon-contract/src/session.ts
@@ -443,6 +443,7 @@ const hostDaemonOnlineRpcResponseSuccessSchema = z.discriminatedUnion(
onlineRpcResponseSuccessSchemaFor("host.read_file_relative"),
onlineRpcResponseSuccessSchemaFor("host.write_file"),
onlineRpcResponseSuccessSchemaFor("provider.list_models"),
+ onlineRpcResponseSuccessSchemaFor("provider.custom_call"),
onlineRpcResponseSuccessSchemaFor("provider.health"),
onlineRpcResponseSuccessSchemaFor("provider.installation.status"),
onlineRpcResponseSuccessSchemaFor("provider.installation.run"),
@@ -458,6 +459,7 @@ const hostDaemonOnlineRpcResponseSuccessSchema = z.discriminatedUnion(
commandRpcResponseSuccessSchemaFor("thread.reload"),
commandRpcResponseSuccessSchemaFor("turn.submit"),
commandRpcResponseSuccessSchemaFor("thread.stop"),
+ commandRpcResponseSuccessSchemaFor("thread.extension-state.action"),
commandRpcResponseSuccessSchemaFor("thread.goal.clear"),
commandRpcResponseSuccessSchemaFor("thread.plan.cancel"),
commandRpcResponseSuccessSchemaFor("thread.rename"),
diff --git a/packages/host-daemon-contract/test/contract.test.ts b/packages/host-daemon-contract/test/contract.test.ts
index 537c6e1022..ff1e109f64 100644
--- a/packages/host-daemon-contract/test/contract.test.ts
+++ b/packages/host-daemon-contract/test/contract.test.ts
@@ -176,6 +176,7 @@ const ONLINE_RPC_RESPONSE_RESULT_FIXTURES: OnlineRpcResponseResultFixtures = {
"plugin.host.call": { output: { ok: true } },
"plugin.host.cancel": { cancelled: true },
"plugin.host.dispose": { disposed: true },
+ "provider.custom_call": { result: { ok: true } },
"connect-tunnel.ensure-identity": {
label: "sawyer-air",
baseDomain: "getbb.app",
@@ -238,6 +239,7 @@ const ONLINE_RPC_RESPONSE_RESULT_FIXTURES: OnlineRpcResponseResultFixtures = {
argumentHint: null,
},
],
+ diagnostics: [],
},
"host.list_skills": {
skills: [
@@ -459,6 +461,7 @@ const SETTLED_RESPONSE_RESULT_FIXTURES: SettledResponseResultFixtures = {
appliedAs: "new-turn",
},
"thread.stop": { providerCheckpointId: null },
+ "thread.extension-state.action": { applied: true },
"thread.goal.clear": { cleared: true },
"thread.plan.cancel": { cancelled: true },
"thread.rename": {},
@@ -642,6 +645,8 @@ const INTENTIONAL_OPTIONAL_HOST_DAEMON_FIELDS: Record = {
"environment.provision only includes checkout instructions for unmanaged workspaces that requested a branch mutation.",
"hostDaemonCommandSchema.targetPath":
"project.clone omits targetPath when the daemon should derive its default checkout location for the project.",
+ "hostDaemonOnlineRpcCommandSchema.bridgeLaunch":
+ "host.list_commands omits bridgeLaunch for providers that use only static skill and command scanning.",
"hostDaemonOnlineRpcCommandSchema.expectedSha256":
"host.write_file may omit expectedSha256 for unconditional writes; a hash is the compare-and-swap guard and null means create-only.",
"hostDaemonOnlineRpcCommandSchema.mode":
@@ -1013,6 +1018,11 @@ describe("host-daemon command schemas", () => {
// completeness over the host wire. Older daemons cannot safely enforce or
// interpret those fields, so enrolled machines must update before serving
// workspace status and diff requests.
+ // Version 165 lets host.list_commands carry a provider bridge launch and
+ // return non-fatal diagnostics (with thread.reload, provider.custom_call,
+ // thread.extension-state.action, and thread-scoped interactions). Older
+ // daemons neither recognize the launch field nor return the required
+ // diagnostic list.
// Version 118 rejects successful provider update results when the daemon
// cannot verify a version change. Older daemons can report a no-op Claude
// update as successful, so enrolled machines must update for honest results.
@@ -1047,7 +1057,7 @@ describe("host-daemon command schemas", () => {
// mixed version. Version 113 carried the Devin Desktop open target rename
// and remains part of the protocol lineage.
it("uses the current host-daemon protocol version", () => {
- expect(HOST_DAEMON_PROTOCOL_VERSION).toBe(171);
+ expect(HOST_DAEMON_PROTOCOL_VERSION).toBe(172);
expect(HOST_ARTIFACT_MAX_BYTES).toBe(256 * 1024 * 1024);
});
diff --git a/packages/plugin-sdk/src/__tests__/provider-declaration-v3.test.ts b/packages/plugin-sdk/src/__tests__/provider-declaration-v3.test.ts
index 6059a7f517..c8d08790ee 100644
--- a/packages/plugin-sdk/src/__tests__/provider-declaration-v3.test.ts
+++ b/packages/plugin-sdk/src/__tests__/provider-declaration-v3.test.ts
@@ -129,7 +129,7 @@ describe("provider declaration target-state fields", () => {
validatePluginProviderDeclaration(
declaration({ extensionKinds: { goal: {} } }),
),
- ).toThrow(/item schema, a state schema, or both/u);
+ ).toThrow(/item schema, a state schema, or an experimental action schema/u);
expect(() =>
validatePluginProviderDeclaration(
declaration({
diff --git a/packages/plugin-sdk/src/__tests__/public-types.test.ts b/packages/plugin-sdk/src/__tests__/public-types.test.ts
index 328d162c76..dae23142f3 100644
--- a/packages/plugin-sdk/src/__tests__/public-types.test.ts
+++ b/packages/plugin-sdk/src/__tests__/public-types.test.ts
@@ -25,6 +25,7 @@ type ExpectedBbPluginApiKey =
const EXPECTED_BACKEND_ROOT_TYPE_EXPORTS = [
"BbPluginApi",
+ "ExperimentalProviderBridgeClient",
"PluginAgents",
"PluginAiServiceDeclaration",
"PluginAiServiceKind",
diff --git a/packages/plugin-sdk/src/app-contract.ts b/packages/plugin-sdk/src/app-contract.ts
index 2b6f4c564f..6468cae759 100644
--- a/packages/plugin-sdk/src/app-contract.ts
+++ b/packages/plugin-sdk/src/app-contract.ts
@@ -1,5 +1,6 @@
import type { ComponentPropsWithoutRef, ComponentType, ReactNode } from "react";
import type {
+ ExtensionKind,
PermissionMode,
PromptInput,
ProviderInfo,
@@ -36,12 +37,15 @@ export interface PluginHomepageSectionProps {
projectId: string | null;
}
-/**
- * Props passed to a `settingsSection` component.
- *
- * Deliberately empty in V1; versioned additive like the other slot props.
- */
-export interface PluginSettingsSectionProps {}
+/** Props passed to a `settingsSection` component. */
+export interface PluginSettingsSectionProps {
+ /**
+ * The host selected by BB's Settings context. Plugins use this for
+ * host-local configuration without adding their own machine picker.
+ * Null while no host is available. Experimental: see docs/api_to_audit.md.
+ */
+ experimental_hostId?: string | null;
+}
/** Props passed to a `navPanel` component (it owns its whole route). */
export interface PluginNavPanelProps {
@@ -1338,6 +1342,43 @@ export interface PluginTimelineRendererRegistration {
component: ComponentType;
}
+/**
+ * Current plugin-declared provider state projected from the latest persisted
+ * `extension.state` snapshot. The host mounts the renderer on both sides of
+ * the composer so the owning plugin can honor native placement semantics.
+ * Experimental: see docs/api_to_audit.md.
+ */
+export interface ExperimentalProviderExtensionStateProps {
+ threadId: string;
+ providerId: string;
+ kind: ExtensionKind;
+ payload: JsonValue;
+ sourceSeq: number;
+ placement: "aboveEditor" | "belowEditor";
+ /**
+ * Validate and dispatch one opaque action to this state kind in the current
+ * provider session. The promise never retries: callers decide whether an
+ * action is safe to repeat. Experimental: see docs/api_to_audit.md.
+ */
+ experimental_dispatchAction(action: JsonValue): Promise<{ applied: boolean }>;
+}
+
+/**
+ * Render one local provider extension-state kind. `name` is the local name
+ * declared by this plugin's backend; the host prefixes the owning plugin id
+ * and never passes another plugin's payload to the component.
+ * Experimental: see docs/api_to_audit.md.
+ */
+export interface ExperimentalProviderExtensionStateRegistration {
+ name: string;
+ component: ComponentType;
+}
+
+/**
+ * Host-owned non-modal drawer. Content realization starts after two animation
+ * frames and remains mounted after the first open. Experimental: see
+ * docs/api_to_audit.md.
+ */
export interface ExperimentalResponsiveDrawerProps {
open: boolean;
onOpenChange(open: boolean): void;
@@ -1441,6 +1482,13 @@ export interface PluginAppSlots {
experimental_timelineRenderer(
registration: PluginTimelineRendererRegistration,
): void;
+ /**
+ * Render current plugin-declared provider state beside the composer.
+ * Experimental: see docs/api_to_audit.md.
+ */
+ experimental_providerExtensionState(
+ registration: ExperimentalProviderExtensionStateRegistration,
+ ): void;
}
export interface PluginAppComposer {
diff --git a/packages/plugin-sdk/src/backend-contract.ts b/packages/plugin-sdk/src/backend-contract.ts
index cd5adf9e5a..545496671a 100644
--- a/packages/plugin-sdk/src/backend-contract.ts
+++ b/packages/plugin-sdk/src/backend-contract.ts
@@ -12,7 +12,9 @@ import type { JsonValue } from "./json-value.js";
import type {
PluginRpcContract,
PluginRpcHandlers,
+ PluginRpcResult,
StandardSchemaV1,
+ StandardSchemaV1InferInput,
} from "./rpc-contract.js";
import type {
ExperimentalHostClient,
@@ -653,13 +655,16 @@ export interface PluginProviderOptionDescriptor {
* Payload schemas for one extension kind this provider emits, keyed by the
* kind's local name (the server prefixes the plugin id to form the
* namespaced `"/"`). `item` validates `item.open` payloads
- * with `type: "extension"`, `state` validates `extension.state` payloads;
- * each is optional so a kind can be item-only or state-only. Schemas are
- * Standard Schema v1 validators (zod 4 schemas qualify).
+ * with `type: "extension"`, `state` validates `extension.state` payloads,
+ * and `experimental_action` validates app-to-bridge actions for that state.
+ * Each is optional so a kind declares only the surfaces it supports. Schemas
+ * are Standard Schema v1 validators (zod 4 schemas qualify).
*/
export interface PluginProviderExtensionKindDeclaration {
item?: StandardSchemaV1;
state?: StandardSchemaV1;
+ /** Experimental: see docs/api_to_audit.md. */
+ experimental_action?: StandardSchemaV1;
}
/**
@@ -988,6 +993,16 @@ export interface PluginAgents {
* registration; `bb.agents` keeps `configure`, `registerTool`, and
* `contributeInstructions`.
*/
+export interface ExperimentalProviderBridgeClient<
+ Contract extends PluginRpcContract,
+> {
+ call(
+ method: MethodName,
+ input: StandardSchemaV1InferInput,
+ options: { readonly hostId: string; readonly signal?: AbortSignal },
+ ): Promise>;
+}
+
export interface PluginProviders {
/**
* Register an agent provider this plugin contributes (see
@@ -1005,6 +1020,25 @@ export interface PluginProviders {
register(declaration: PluginProviderDeclaration): {
dispose(): void;
};
+ /**
+ * Create a typed client for one provider this plugin owns. Calls route to
+ * that provider's bridge on an explicit host; core validates JSON framing
+ * but never interprets the provider-owned method or payload.
+ * Experimental: see docs/api_to_audit.md.
+ */
+ experimental_client(args: {
+ providerId: string;
+ contract: Contract;
+ }): ExperimentalProviderBridgeClient;
+ /**
+ * Clear cached model catalogs and notify clients after this plugin changes
+ * native model preferences on one host.
+ * Experimental: see docs/api_to_audit.md.
+ */
+ experimental_modelsChanged(args: {
+ providerId: string;
+ hostId: string;
+ }): void;
}
// ---------------------------------------------------------------------------
diff --git a/packages/plugin-sdk/src/internal/host-policy.ts b/packages/plugin-sdk/src/internal/host-policy.ts
index 7943b3c85f..d5a53f410b 100644
--- a/packages/plugin-sdk/src/internal/host-policy.ts
+++ b/packages/plugin-sdk/src/internal/host-policy.ts
@@ -406,9 +406,7 @@ function normalizeProviderBridgeOptions(
return current;
}
if (typeof current !== "object") {
- throw new Error(
- `provider "${providerId}" ${label}${path} must be JSON`,
- );
+ throw new Error(`provider "${providerId}" ${label}${path} must be JSON`);
}
if (active.has(current)) {
throw new Error(
@@ -449,9 +447,7 @@ function normalizeProviderBridgeOptions(
Array.isArray(normalized) ||
typeof normalized !== "object"
) {
- throw new Error(
- `provider "${providerId}" ${label} must be an object`,
- );
+ throw new Error(`provider "${providerId}" ${label} must be an object`);
}
if (
Buffer.byteLength(JSON.stringify(normalized), "utf8") >
@@ -632,14 +628,15 @@ function validateProviderExtensionKinds(
Array.isArray(declaration)
) {
throw new Error(
- `provider "${providerId}" extensionKinds.${name} must be { item?, state? }`,
+ `provider "${providerId}" extensionKinds.${name} must be { item?, state?, experimental_action? }`,
);
}
const item = Reflect.get(declaration, "item");
const state = Reflect.get(declaration, "state");
- if (item === undefined && state === undefined) {
+ const action = Reflect.get(declaration, "experimental_action");
+ if (item === undefined && state === undefined && action === undefined) {
throw new Error(
- `provider "${providerId}" extensionKinds.${name} must declare an item schema, a state schema, or both`,
+ `provider "${providerId}" extensionKinds.${name} must declare an item schema, a state schema, or an experimental action schema`,
);
}
if (item !== undefined && !isStandardSchema(item)) {
@@ -652,9 +649,15 @@ function validateProviderExtensionKinds(
`provider "${providerId}" extensionKinds.${name}.state must be a Standard Schema v1 validator`,
);
}
+ if (action !== undefined && !isStandardSchema(action)) {
+ throw new Error(
+ `provider "${providerId}" extensionKinds.${name}.experimental_action must be a Standard Schema v1 validator`,
+ );
+ }
normalized[name] = Object.freeze({
...(item === undefined ? {} : { item }),
...(state === undefined ? {} : { state }),
+ ...(action === undefined ? {} : { experimental_action: action }),
});
}
return Object.freeze(normalized);
diff --git a/packages/plugin-sdk/src/internal/plugin-app-collector.ts b/packages/plugin-sdk/src/internal/plugin-app-collector.ts
index 663394ac4d..544840e81c 100644
--- a/packages/plugin-sdk/src/internal/plugin-app-collector.ts
+++ b/packages/plugin-sdk/src/internal/plugin-app-collector.ts
@@ -1,6 +1,7 @@
import type {
ComposerCustomization,
ExperimentalChangesViewRegistration,
+ ExperimentalProviderExtensionStateRegistration,
ExperimentalSidebarNavigationRegistration,
PluginAppDefinition,
PluginContentScriptRegistration,
@@ -82,6 +83,7 @@ function rejectStaleNavPanelKeys(kind: string, registration: object): void {
}
}
}
+const PROVIDER_EXTENSION_STATE_NAME_PATTERN = /^[a-z0-9-]+$/u;
/** Validated registrations produced by one plugin app setup execution. */
export interface CollectedPluginAppRegistrations {
@@ -105,6 +107,7 @@ export interface CollectedPluginAppRegistrations {
commandPaletteActions: PluginCommandPaletteActionRegistration[];
providerIcons: PluginProviderIconRegistration[];
timelineRenderers: PluginTimelineRendererRegistration[];
+ providerExtensionStates: ExperimentalProviderExtensionStateRegistration[];
contentScripts: PluginContentScriptRegistration[];
}
@@ -140,6 +143,7 @@ export function collectPluginAppRegistrations(
commandPaletteActions: [],
providerIcons: [],
timelineRenderers: [],
+ providerExtensionStates: [],
contentScripts: [],
};
const seenIds = {
@@ -163,6 +167,7 @@ export function collectPluginAppRegistrations(
commandPaletteAction: new Set(),
providerIcon: new Set(),
timelineRenderer: new Set(),
+ providerExtensionState: new Set(),
contentScript: new Set(),
};
@@ -575,6 +580,20 @@ export function collectPluginAppRegistrations(
component: requireComponent(kind, registration.component),
});
},
+ experimental_providerExtensionState(registration) {
+ const kind = "slots.experimental_providerExtensionState";
+ const name = requireNonEmptyString(kind, "name", registration?.name);
+ if (!PROVIDER_EXTENSION_STATE_NAME_PATTERN.test(name)) {
+ throw new Error(
+ `${kind}: "name" must match ${String(PROVIDER_EXTENSION_STATE_NAME_PATTERN)}, got ${JSON.stringify(name)}`,
+ );
+ }
+ requireUniqueId(kind, seenIds.providerExtensionState, name);
+ collected.providerExtensionStates.push({
+ name,
+ component: requireComponent(kind, registration.component),
+ });
+ },
},
composer: {
customize(registration) {
diff --git a/packages/plugin-sdk/src/provider-bridge.ts b/packages/plugin-sdk/src/provider-bridge.ts
index 8d77292be7..db7c421304 100644
--- a/packages/plugin-sdk/src/provider-bridge.ts
+++ b/packages/plugin-sdk/src/provider-bridge.ts
@@ -86,10 +86,19 @@ export {
providerRecoveryHintSchema,
providerRecoveryNotificationSchema,
bridgeErrorDataSchema,
+ interactionRequestParamsSchema as experimental_interactionRequestParamsSchema,
+ experimental_interactionCancelNotificationSchema,
threadDeltaNotificationParamsSchema,
threadDeltaSchema,
initializeParamsSchema,
modelListParamsSchema,
+ experimental_providerCustomCallParamsSchema,
+ experimental_providerCustomCallResultSchema,
+ experimental_providerCommandListParamsSchema,
+ experimental_providerCommandListResultSchema,
+ experimental_providerCommandSchema,
+ experimental_extensionActionParamsSchema,
+ experimental_extensionActionResultSchema,
providerHealthResultSchema,
providerHealthSchema,
providerInstallationActionKindSchema,
@@ -124,6 +133,8 @@ export type {
BridgeExecutionOptions,
BridgeGrammarVersions,
BridgeSteerMode,
+ ExperimentalInteractionCancelNotification,
+ InteractionRequestParams as ExperimentalInteractionRequestParams,
DeltaBackgroundTaskShape,
DeltaDelegationShape,
DeltaExtensionShape,
@@ -142,6 +153,11 @@ export type {
ProviderRecoveryHint,
ProviderRecoveryNotification,
BridgeErrorData,
+ ExperimentalExtensionActionParams,
+ ExperimentalExtensionActionResult,
+ ExperimentalProviderCommand,
+ ExperimentalProviderCommandListParams,
+ ExperimentalProviderCommandListResult,
ProviderHealth,
ProviderHealthResult,
ProviderInstallationAction,
@@ -295,6 +311,7 @@ export {
pendingInteractionNetworkPermissionsSchema,
pendingInteractionRequestedPermissionProfileSchema,
pendingInteractionResolutionSchema,
+ USER_QUESTION_MAX_FREE_TEXT_LENGTH,
providerInteractionOutcomeSchema,
userQuestionInteractionOutcomeSchema,
permissionEscalationValues,
diff --git a/packages/plugin-sdk/src/testing/__tests__/fake-plugin-host.test.ts b/packages/plugin-sdk/src/testing/__tests__/fake-plugin-host.test.ts
index 9ce00d1a9e..9ffb0c7107 100644
--- a/packages/plugin-sdk/src/testing/__tests__/fake-plugin-host.test.ts
+++ b/packages/plugin-sdk/src/testing/__tests__/fake-plugin-host.test.ts
@@ -1448,18 +1448,16 @@ describe("providers.register", () => {
it("clears registrations on dispose", async () => {
const { bb, harness } = createFakePluginHost();
- bb.providers.register(
- agentDeclaration({ id: "my-second-agent" }),
- );
+ bb.providers.register(agentDeclaration({ id: "my-second-agent" }));
expect(
harness.registrations.providerRegistrations.map((entry) => entry.id),
).toEqual(["my-second-agent"]);
await harness.dispose();
expect(harness.registrations.providerRegistrations).toEqual([]);
- expect(() =>
- bb.providers.register(agentDeclaration()),
- ).toThrow("used a stale API handle");
+ expect(() => bb.providers.register(agentDeclaration())).toThrow(
+ "used a stale API handle",
+ );
});
});
diff --git a/packages/plugin-sdk/src/testing/app.tsx b/packages/plugin-sdk/src/testing/app.tsx
index dcab5e66eb..055d8f01c8 100644
--- a/packages/plugin-sdk/src/testing/app.tsx
+++ b/packages/plugin-sdk/src/testing/app.tsx
@@ -39,6 +39,7 @@ import {
type PluginPendingInteractionRegistration,
type PluginProviderIconRegistration,
type PluginTimelineRendererRegistration,
+ type ExperimentalProviderExtensionStateRegistration,
type PluginRealtimeConnectionState,
type PluginRpcClient,
type PluginSdkApp,
@@ -916,6 +917,7 @@ export interface CapturedPluginApp {
messageActions: PluginMessageActionRegistration[];
providerIcons: PluginProviderIconRegistration[];
timelineRenderers: PluginTimelineRendererRegistration[];
+ providerExtensionStates: ExperimentalProviderExtensionStateRegistration[];
contentScripts: PluginContentScriptRegistration[];
}
diff --git a/packages/plugin-sdk/src/testing/fake-plugin-host.ts b/packages/plugin-sdk/src/testing/fake-plugin-host.ts
index 1f485f3e2a..3c19a7b0e5 100644
--- a/packages/plugin-sdk/src/testing/fake-plugin-host.ts
+++ b/packages/plugin-sdk/src/testing/fake-plugin-host.ts
@@ -223,6 +223,10 @@ export interface ExperimentalFakeHostRpcCall {
signal?: AbortSignal;
}
+export interface ExperimentalFakeProviderBridgeRpcCall extends ExperimentalFakeHostRpcCall {
+ providerId: string;
+}
+
export type ExperimentalFakeInvocationDecision =
| { allowed: true }
| { allowed: false; reason: string };
@@ -273,6 +277,12 @@ export interface FakePluginInspectionState {
}>;
/** Calls made through bb.hosts.experimental_client, after input validation. */
readonly experimental_hostRpcCalls: readonly ExperimentalFakeHostRpcCall[];
+ /** Calls made through bb.providers.experimental_client. */
+ readonly experimental_providerBridgeRpcCalls: readonly ExperimentalFakeProviderBridgeRpcCall[];
+ readonly experimental_providerModelChanges: readonly {
+ providerId: string;
+ hostId: string;
+ }[];
readonly pendingInteractions: readonly (PluginInteractionRequest & {
id: string;
})[];
@@ -452,6 +462,10 @@ export interface CreateFakePluginHostOptions {
experimental_callHostRpc?: (
call: ExperimentalFakeHostRpcCall,
) => unknown | Promise;
+ /** Deterministic stand-in for a provider bridge on one host. */
+ experimental_callProviderBridgeRpc?: (
+ call: ExperimentalFakeProviderBridgeRpcCall,
+ ) => unknown | Promise;
}
export interface FakePluginHost {
@@ -1681,6 +1695,9 @@ function createFakePluginHostInternal(
const sharedPortDeclarations: FakePluginHarness["sharedPortDeclarations"] =
[];
const hostRpcCalls: ExperimentalFakeHostRpcCall[] = [];
+ const providerBridgeRpcCalls: ExperimentalFakeProviderBridgeRpcCall[] = [];
+ const providerModelChanges: Array<{ providerId: string; hostId: string }> =
+ [];
const hostWorkerExitSubscriptions: FakeHostWorkerExitSubscription[] = [];
const hostSignalSubscriptions: FakeHostSignalSubscription[] = [];
const hosts: PluginHosts = {
@@ -1855,6 +1872,65 @@ function createFakePluginHostInternal(
register(declaration) {
return registerProviderDeclaration(declaration);
},
+ experimental_client({ providerId, contract }) {
+ return {
+ async call(method, input, callOptions) {
+ assertLive();
+ const methodContract = contract[method];
+ if (methodContract === undefined) {
+ throw new Error(`unknown provider bridge rpc method "${method}"`);
+ }
+ // The same gates the real host applies: a plugin may only reach
+ // its own providers' bridges, and only on a named host.
+ if (!providerRegistrations.some((entry) => entry.id === providerId)) {
+ throw new Error(
+ `plugin "${pluginId}" does not own provider "${providerId}"`,
+ );
+ }
+ if (typeof callOptions?.hostId !== "string" || callOptions.hostId.length === 0) {
+ throw new Error(
+ `provider bridge rpc method "${method}" requires a host id`,
+ );
+ }
+ const validatedInput = await validateRpcValue(
+ methodContract.input,
+ input,
+ "input",
+ );
+ const call = {
+ providerId,
+ method,
+ input: jsonRoundTrip(validatedInput, "provider bridge rpc input"),
+ hostId: callOptions.hostId,
+ ...(callOptions.signal === undefined
+ ? {}
+ : { signal: callOptions.signal }),
+ };
+ providerBridgeRpcCalls.push(call);
+ if (options.experimental_callProviderBridgeRpc === undefined) {
+ throw new Error(
+ `fake plugin host has no experimental_callProviderBridgeRpc stub for "${String(method)}"`,
+ );
+ }
+ const rawOutput =
+ await options.experimental_callProviderBridgeRpc(call);
+ return await validateRpcValue(
+ methodContract.output,
+ jsonRoundTrip(rawOutput, "provider bridge rpc output"),
+ "output",
+ );
+ },
+ };
+ },
+ experimental_modelsChanged(args) {
+ assertLive();
+ if (!providerRegistrations.some((entry) => entry.id === args.providerId)) {
+ throw new Error(
+ `plugin "${pluginId}" does not own provider "${args.providerId}"`,
+ );
+ }
+ providerModelChanges.push({ ...args });
+ },
};
const bb: BbPluginApi = {
@@ -1934,6 +2010,8 @@ function createFakePluginHostInternal(
needsConfigurationMessages,
sharedPortDeclarations,
experimental_hostRpcCalls: hostRpcCalls,
+ experimental_providerBridgeRpcCalls: providerBridgeRpcCalls,
+ experimental_providerModelChanges: providerModelChanges,
sdk: sdkHarness,
registrations: {
settingsDescriptors,
diff --git a/packages/provider-bridge-protocol/src/bridge-kit/contracts.ts b/packages/provider-bridge-protocol/src/bridge-kit/contracts.ts
index 303522aef3..ed78b8865f 100644
--- a/packages/provider-bridge-protocol/src/bridge-kit/contracts.ts
+++ b/packages/provider-bridge-protocol/src/bridge-kit/contracts.ts
@@ -58,6 +58,13 @@ export interface DecodedToolCallRequest {
threadId?: string;
}
+export interface DecodedInteractiveCancellation {
+ requestId: string | number;
+ providerThreadId: string;
+ reason: string;
+ threadId?: string;
+}
+
export interface DecodedInteractiveRequest {
requestId: string | number;
method: string;
@@ -68,6 +75,8 @@ export interface DecodedInteractiveRequest {
* malformed adapter output.
*/
turnId: string | null;
+ /** `thread` only for a user question that is not owned by a provider turn. */
+ scope: "active_turn" | "thread";
payload: PendingInteractionPayload;
threadId?: string;
}
diff --git a/packages/provider-bridge-protocol/src/bridge-requests.ts b/packages/provider-bridge-protocol/src/bridge-requests.ts
index 886039e0b6..13f1642795 100644
--- a/packages/provider-bridge-protocol/src/bridge-requests.ts
+++ b/packages/provider-bridge-protocol/src/bridge-requests.ts
@@ -54,6 +54,12 @@ export const interactionRequestParamsSchema = z
threadId: z.string().min(1).optional(),
turnId: z.union([z.string().min(1), z.null()]),
payload: pendingInteractionPayloadSchema,
+ /**
+ * Explicitly scopes a request to the thread when no provider turn exists.
+ * Omission preserves the established meaning of `turnId: null`: resolve
+ * the active turn at the runtime boundary.
+ */
+ experimental_scope: z.literal("thread").optional(),
/**
* The request's turn id and approval-subject item ids are in the
* provider's native id space (a `thread/delta` bridge holds no bb ids):
@@ -64,7 +70,20 @@ export const interactionRequestParamsSchema = z
*/
providerNativeIds: z.boolean().optional(),
})
- .passthrough();
+ .passthrough()
+ .superRefine((request, context) => {
+ if (
+ request.experimental_scope === "thread" &&
+ (request.turnId !== null || request.payload.kind !== "user_question")
+ ) {
+ context.addIssue({
+ code: z.ZodIssueCode.custom,
+ message:
+ "Thread-scoped interactions must be user questions with a null turn id",
+ path: ["experimental_scope"],
+ });
+ }
+ });
export type InteractionRequestParams = z.infer<
typeof interactionRequestParamsSchema
diff --git a/packages/provider-bridge-protocol/src/contract-tests/provider-bridge-grammar.v2.snapshot.json b/packages/provider-bridge-protocol/src/contract-tests/provider-bridge-grammar.v2.snapshot.json
index 70a5c5588e..6d42570409 100644
--- a/packages/provider-bridge-protocol/src/contract-tests/provider-bridge-grammar.v2.snapshot.json
+++ b/packages/provider-bridge-protocol/src/contract-tests/provider-bridge-grammar.v2.snapshot.json
@@ -297,8 +297,11 @@
"retryable": "required"
},
"requestMethods": [
+ "command/list",
+ "extension/action",
"initialize",
"model/list",
+ "provider/custom",
"provider/health",
"provider/installation/run",
"provider/installation/status",
@@ -318,6 +321,7 @@
],
"notificationMethods": [
"error",
+ "interaction/cancel",
"provider/raw",
"provider/recovery",
"session/replaced",
diff --git a/packages/provider-bridge-protocol/src/index.ts b/packages/provider-bridge-protocol/src/index.ts
index 8a02fdf5ac..1bf75debf4 100644
--- a/packages/provider-bridge-protocol/src/index.ts
+++ b/packages/provider-bridge-protocol/src/index.ts
@@ -15,6 +15,7 @@ export * from "./version.js";
export * from "./handshake.js";
export * from "./execution-options.js";
export * from "./provider-maintenance.js";
+export * from "./provider-commands.js";
export * from "./requests.js";
export * from "./notifications.js";
export * from "./bridge-requests.js";
diff --git a/packages/provider-bridge-protocol/src/notifications.ts b/packages/provider-bridge-protocol/src/notifications.ts
index 517e68c9a1..6826d77b61 100644
--- a/packages/provider-bridge-protocol/src/notifications.ts
+++ b/packages/provider-bridge-protocol/src/notifications.ts
@@ -13,6 +13,7 @@ export const BRIDGE_NOTIFICATION_METHODS = {
sessionReplaced: "session/replaced",
providerRaw: "provider/raw",
providerRecovery: "provider/recovery",
+ experimentalInteractionCancel: "interaction/cancel",
error: "error",
} as const;
@@ -92,6 +93,18 @@ export type ProviderRecoveryNotification = z.infer<
typeof providerRecoveryNotificationSchema
>;
+export const experimental_interactionCancelNotificationSchema = z
+ .object({
+ requestId: z.union([z.string(), z.number()]),
+ providerThreadId: z.string().min(1),
+ threadId: z.string().min(1).optional(),
+ reason: z.string().min(1),
+ })
+ .passthrough();
+export type ExperimentalInteractionCancelNotification = z.infer<
+ typeof experimental_interactionCancelNotificationSchema
+>;
+
export const errorNotificationSchema = z
.object({
threadId: z.string().min(1).optional(),
diff --git a/packages/provider-bridge-protocol/src/provider-commands.ts b/packages/provider-bridge-protocol/src/provider-commands.ts
new file mode 100644
index 0000000000..696d7799d6
--- /dev/null
+++ b/packages/provider-bridge-protocol/src/provider-commands.ts
@@ -0,0 +1,52 @@
+import { z } from "zod";
+
+/**
+ * Sessionless provider command discovery. The cwd is required because command
+ * resources can be project-scoped and must never inherit the bridge process's
+ * ambient working directory.
+ */
+export const experimental_providerCommandListParamsSchema = z
+ .object({
+ providerId: z.string().min(1),
+ cwd: z.string().min(1),
+ providerOptions: z.record(z.string(), z.unknown()).optional(),
+ })
+ .passthrough();
+
+export type ExperimentalProviderCommandListParams = z.infer<
+ typeof experimental_providerCommandListParamsSchema
+>;
+
+export const experimental_providerCommandSchema = z
+ .object({
+ name: z.string().min(1),
+ source: z.enum(["skill", "command"]),
+ origin: z.enum(["project", "user"]),
+ description: z.string().nullable(),
+ argumentHint: z.string().nullable(),
+ })
+ .passthrough();
+
+export type ExperimentalProviderCommand = z.infer<
+ typeof experimental_providerCommandSchema
+>;
+
+/**
+ * Partial discovery is successful: diagnostics describe broken resources while
+ * commands from healthy resources remain available.
+ */
+export const experimental_providerCommandListResultSchema =
+ z.discriminatedUnion("supported", [
+ z.object({ supported: z.literal(false) }).passthrough(),
+ z
+ .object({
+ supported: z.literal(true),
+ commands: z.array(experimental_providerCommandSchema),
+ diagnostics: z.array(z.string()),
+ })
+ .passthrough(),
+ ]);
+
+export type ExperimentalProviderCommandListResult = z.infer<
+ typeof experimental_providerCommandListResultSchema
+>;
diff --git a/packages/provider-bridge-protocol/src/requests.ts b/packages/provider-bridge-protocol/src/requests.ts
index 3518b8e423..65a6f718c7 100644
--- a/packages/provider-bridge-protocol/src/requests.ts
+++ b/packages/provider-bridge-protocol/src/requests.ts
@@ -2,7 +2,9 @@ import {
availableModelSchema,
clientTurnRequestIdSchema,
dynamicToolSchema,
+ extensionKindSchema,
instructionModeSchema,
+ jsonValueSchema,
promptInputSchema,
} from "@bb/domain";
import { z } from "zod";
@@ -18,6 +20,8 @@ import { bridgeExecutionOptionsSchema } from "./execution-options.js";
export const BRIDGE_REQUEST_METHODS = {
initialize: "initialize",
modelList: "model/list",
+ experimentalCustomCall: "provider/custom",
+ experimentalProviderCommandList: "command/list",
providerHealth: "provider/health",
providerUsage: "provider/usage",
providerInstallationStatus: "provider/installation/status",
@@ -31,6 +35,7 @@ export const BRIDGE_REQUEST_METHODS = {
threadArchive: "thread/archive",
threadUnarchive: "thread/unarchive",
threadGoalClear: "thread/goal/clear",
+ experimentalExtensionAction: "extension/action",
turnStart: "turn/start",
turnSteer: "turn/steer",
skillsConfigure: "skills/configure",
@@ -49,6 +54,17 @@ export const modelListParamsSchema = z
.object({ cwd: z.string().min(1).optional() })
.passthrough();
+export const experimental_providerCustomCallParamsSchema = z
+ .object({
+ method: z.string().min(1),
+ input: jsonValueSchema,
+ })
+ .strict();
+
+export const experimental_providerCustomCallResultSchema = z
+ .object({ result: jsonValueSchema })
+ .strict();
+
export const threadStartParamsSchema = z
.object({
...sessionConstructionFields,
@@ -104,6 +120,24 @@ export const threadArchiveParamsSchema = threadRefParams;
export const threadUnarchiveParamsSchema = threadRefParams;
export const threadGoalClearParamsSchema = threadRefParams;
+export const experimental_extensionActionParamsSchema = threadRefParams
+ .extend({
+ extensionKind: extensionKindSchema,
+ action: jsonValueSchema,
+ })
+ .passthrough();
+
+export const experimental_extensionActionResultSchema = z
+ .object({ applied: z.boolean() })
+ .passthrough();
+
+export type ExperimentalExtensionActionParams = z.infer<
+ typeof experimental_extensionActionParamsSchema
+>;
+export type ExperimentalExtensionActionResult = z.infer<
+ typeof experimental_extensionActionResultSchema
+>;
+
export const threadNameSetParamsSchema = z
.object({
threadId: z.string().min(1),
diff --git a/packages/sdk/src/areas/threads.ts b/packages/sdk/src/areas/threads.ts
index 50b43787f6..5b7039aa80 100644
--- a/packages/sdk/src/areas/threads.ts
+++ b/packages/sdk/src/areas/threads.ts
@@ -17,6 +17,8 @@ import {
import type {
CreateQueuedMessageRequest,
CreateThreadRequest,
+ ExperimentalExtensionStateActionRequest,
+ ExperimentalExtensionStateActionResponse,
ExperimentalThreadReloadResponse,
EditMessageRequest,
EditMessageResponse,
@@ -126,6 +128,8 @@ export type ThreadDeleteResult = { ok: true };
export type ThreadSendResult = SendMessageResponse;
export type ThreadEditMessageResult = EditMessageResponse;
export type ThreadStopResult = { ok: true };
+export type ExperimentalThreadExtensionStateActionResult =
+ ExperimentalExtensionStateActionResponse;
export type ExperimentalThreadReloadResult = ExperimentalThreadReloadResponse;
export type ThreadCompactResult = { ok: true };
export type ThreadBannerActionResult = { ok: true };
@@ -433,6 +437,11 @@ export interface ThreadTabsArea {
update(args: ThreadTabsUpdateArgs): Promise;
}
+export interface ExperimentalThreadExtensionStateActionArgs extends ExperimentalExtensionStateActionRequest {
+ threadId: string;
+ signal?: AbortSignal;
+}
+
export interface ThreadsArea {
archive(args: ThreadActionArgs): Promise;
archiveAll(args: ThreadActionArgs): Promise;
@@ -448,6 +457,11 @@ export interface ThreadsArea {
): Promise;
delete(args: ThreadDeleteArgs): Promise;
editMessage(args: ThreadEditMessageArgs): Promise;
+ /** Experimental: dispatch one validated action to current provider state. */
+ experimental_applyExtensionStateAction(
+ args: ExperimentalThreadExtensionStateActionArgs,
+ ): Promise;
+ /** Experimental: recreate an idle provider session from current config. */
experimental_reload(
args: ThreadStatusArgs,
): Promise;
@@ -1086,6 +1100,15 @@ export function createThreadsArea(args: CreateSdkAreaArgs): ThreadsArea {
);
return experimental_threadReloadResponseSchema.parse(body);
},
+ async experimental_applyExtensionStateAction(input) {
+ return transport.readJson(
+ transport.api.v1.threads[":id"]["extension-state"].action.$post({
+ param: { id: input.threadId },
+ json: { kind: input.kind, action: input.action },
+ ...signalRequestArgs(input.signal),
+ }),
+ );
+ },
async stop(input) {
await transport.readVoid(
transport.api.v1.threads[":id"].stop.$post({
diff --git a/packages/sdk/test/public-types.test.ts b/packages/sdk/test/public-types.test.ts
index 8bead7f212..107e9bb301 100644
--- a/packages/sdk/test/public-types.test.ts
+++ b/packages/sdk/test/public-types.test.ts
@@ -361,6 +361,7 @@ type ExpectedThreadsKey =
| "delete"
| "editMessage"
| "events"
+ | "experimental_applyExtensionStateAction"
| "experimental_reload"
| "fork"
| "get"
diff --git a/packages/server-contract/src/api/projects.ts b/packages/server-contract/src/api/projects.ts
index 17ab5cec77..9eaa87e7e9 100644
--- a/packages/server-contract/src/api/projects.ts
+++ b/packages/server-contract/src/api/projects.ts
@@ -350,6 +350,7 @@ export function providerCommandSectionRank(cmd: {
export const commandListResponseSchema = z.object({
commands: z.array(providerCommandSchema),
+ diagnostics: z.array(z.string()),
});
export type CommandListResponse = z.infer;
diff --git a/packages/server-contract/src/api/threads.ts b/packages/server-contract/src/api/threads.ts
index 8549103e08..9d3f7800b7 100644
--- a/packages/server-contract/src/api/threads.ts
+++ b/packages/server-contract/src/api/threads.ts
@@ -3,6 +3,7 @@ import {
activeThinkingSchema,
callerExecutionInputSourceSchema,
environmentSchema,
+ extensionKindSchema,
jsonValueSchema,
pendingInteractionResolutionSchema,
pendingInteractionSchema,
@@ -833,6 +834,36 @@ export type ExperimentalThreadReloadResponse = z.infer<
typeof experimental_threadReloadResponseSchema
>;
+export const experimental_extensionStateActionRequestSchema = z
+ .object({
+ kind: extensionKindSchema,
+ action: jsonValueSchema,
+ })
+ .strict();
+export type ExperimentalExtensionStateActionRequest = z.infer<
+ typeof experimental_extensionStateActionRequestSchema
+>;
+
+export const experimental_extensionStateActionResponseSchema = z
+ .object({ applied: z.boolean() })
+ .strict();
+export type ExperimentalExtensionStateActionResponse = z.infer<
+ typeof experimental_extensionStateActionResponseSchema
+>;
+
+const THREAD_TIMELINE_EXTENSION_STATE_MAX = 32;
+
+export const threadTimelineExtensionStateSchema = z
+ .object({
+ kind: extensionKindSchema,
+ payload: jsonValueSchema,
+ sourceSeq: z.number().int().nonnegative(),
+ })
+ .strict();
+export type ThreadTimelineExtensionState = z.infer<
+ typeof threadTimelineExtensionStateSchema
+>;
+
export const threadTimelineResponseSchema = z.object({
rows: z.array(timelineRowSchema),
activePromptMode: threadTimelineActivePromptModeSchema.nullable(),
@@ -843,6 +874,10 @@ export const threadTimelineResponseSchema = z.object({
pendingTodos: threadTimelinePendingTodosSchema.nullable(),
goal: threadTimelineGoalSchema.nullable(),
modelFallback: threadTimelineModelFallbackSchema.nullable(),
+ /** Latest persisted plugin state per declared kind, bounded by declaration. */
+ extensionStates: z
+ .array(threadTimelineExtensionStateSchema)
+ .max(THREAD_TIMELINE_EXTENSION_STATE_MAX),
contextWindowUsage: threadContextWindowUsageSchema.optional(),
timelinePage: timelinePageMetadataSchema,
/** Thread high-water event sequence this window reflects; bumps on append. */
diff --git a/packages/server-contract/src/public-api.ts b/packages/server-contract/src/public-api.ts
index 2aab33004f..fb056795f9 100644
--- a/packages/server-contract/src/public-api.ts
+++ b/packages/server-contract/src/public-api.ts
@@ -167,6 +167,8 @@ import type {
TerminalResizeRequest,
ThreadArchiveAllResponse,
ThreadChildSummaryResponse,
+ ExperimentalExtensionStateActionRequest,
+ ExperimentalExtensionStateActionResponse,
ExperimentalThreadReloadResponse,
ThreadEventWaitQuery,
ThreadEventsQuery,
@@ -239,6 +241,7 @@ import {
environmentDiffQuerySchema,
environmentPathsQuerySchema,
environmentStatusQuerySchema,
+ experimental_extensionStateActionRequestSchema,
hostDirectoryQuerySchema,
hostCloneDefaultPathQuerySchema,
hostFileListRequestSchema,
@@ -1081,6 +1084,14 @@ export const publicApiRoutes = {
request: noRequest(),
response: jsonResponse(),
}),
+ experimental_extensionStateAction: defineRoute({
+ path: "/threads/:id/extension-state/action",
+ method: "post",
+ request: jsonRequest(
+ experimental_extensionStateActionRequestSchema,
+ ),
+ response: jsonResponse(),
+ }),
compact: defineRoute({
path: "/threads/:id/compact",
method: "post",
diff --git a/packages/templates/src/templates/bb-guide-projects.md b/packages/templates/src/templates/bb-guide-projects.md
index e8b892a2d9..eaa1b32b92 100644
--- a/packages/templates/src/templates/bb-guide-projects.md
+++ b/packages/templates/src/templates/bb-guide-projects.md
@@ -46,7 +46,8 @@ Discovery:
The machine/host and environment selectors are mutually exclusive. An
environment selects its owning machine and workspace; otherwise an explicit
machine selects that machine's project source. Omitting both intentionally
- falls back to the primary machine's project source.
+ falls back to the primary machine's project source. The result includes
+ non-fatal discovery diagnostics alongside commands that loaded successfully.
Attachments:
diff --git a/packages/thread-view/src/build-thread-timeline.ts b/packages/thread-view/src/build-thread-timeline.ts
index 03942f8853..8a56965230 100644
--- a/packages/thread-view/src/build-thread-timeline.ts
+++ b/packages/thread-view/src/build-thread-timeline.ts
@@ -1,5 +1,6 @@
import type {
ThreadContextWindowUsage,
+ ThreadTimelineExtensionState,
TimelineActivityIntent,
TimelineConversationAttachments,
TimelineFileChange,
@@ -66,6 +67,7 @@ import {
type PlanCommand,
} from "./active-prompt-mode-extraction.js";
import { extractThreadTimelineGoal } from "./goal-snapshot-extraction.js";
+import { extractThreadTimelineExtensionStates } from "./extension-state-extraction.js";
import { extractThreadTimelineModelFallback } from "./model-fallback-extraction.js";
import { extractThreadTimelinePendingTodos } from "./todo-snapshot-extraction.js";
import { buildTimelineErrorDisplay } from "./error-display.js";
@@ -132,6 +134,7 @@ export interface ThreadTimelineFromEventsResult {
contextWindowUsage: ThreadContextWindowUsage | null;
goal: ThreadTimelineGoal | null;
modelFallback: ThreadTimelineModelFallback | null;
+ extensionStates: ThreadTimelineExtensionState[];
pendingTodos: ThreadTimelinePendingTodos | null;
rows: TimelineRow[];
}
@@ -1440,6 +1443,9 @@ export function buildThreadTimelineFromEvents(
modelFallback: !args.options.isLatestPage
? null
: extractThreadTimelineModelFallback(args.events),
+ extensionStates: !args.options.isLatestPage
+ ? []
+ : extractThreadTimelineExtensionStates(args.events),
pendingTodos: !args.options.isLatestPage
? null
: extractThreadTimelinePendingTodos(
diff --git a/packages/thread-view/src/extension-state-extraction.ts b/packages/thread-view/src/extension-state-extraction.ts
new file mode 100644
index 0000000000..7e252ec294
--- /dev/null
+++ b/packages/thread-view/src/extension-state-extraction.ts
@@ -0,0 +1,23 @@
+import type { ThreadTimelineExtensionState } from "@bb/server-contract";
+import type { ThreadEventWithMeta } from "./build-event-projection.js";
+import { getOrderedThreadEvents } from "./group-event-projection-turns.js";
+
+const THREAD_TIMELINE_EXTENSION_STATE_MAX = 32;
+
+/** Latest snapshot wins independently for each plugin-declared state kind. */
+export function extractThreadTimelineExtensionStates(
+ events: readonly ThreadEventWithMeta[],
+): ThreadTimelineExtensionState[] {
+ const latestByKind = new Map();
+ for (const { event, meta } of getOrderedThreadEvents(events)) {
+ if (event.type !== "thread/extensionState/updated") continue;
+ latestByKind.set(event.kind, {
+ kind: event.kind,
+ payload: event.payload,
+ sourceSeq: meta.seq,
+ });
+ }
+ return [...latestByKind.values()]
+ .sort((left, right) => left.kind.localeCompare(right.kind))
+ .slice(0, THREAD_TIMELINE_EXTENSION_STATE_MAX);
+}
diff --git a/packages/thread-view/src/timeline-row-title.ts b/packages/thread-view/src/timeline-row-title.ts
index 1d6bdbf3d6..65702c1e45 100644
--- a/packages/thread-view/src/timeline-row-title.ts
+++ b/packages/thread-view/src/timeline-row-title.ts
@@ -1256,6 +1256,9 @@ function singleQuestionAnswerSummary(
if (answer.freeText) {
parts.push(answer.freeText);
}
+ if (answer.experimental_verbatimText !== undefined) {
+ parts.push(answer.experimental_verbatimText);
+ }
const text = parts.join(", ");
return text.length > 0 ? text : null;
}
diff --git a/plugins/provider-codex/src/interactive-requests.test.ts b/plugins/provider-codex/src/interactive-requests.test.ts
index 5a6a1a7d92..c57c19ee14 100644
--- a/plugins/provider-codex/src/interactive-requests.test.ts
+++ b/plugins/provider-codex/src/interactive-requests.test.ts
@@ -48,6 +48,7 @@ describe("decodeCodexInteractiveRequest", () => {
method: "item/commandExecution/requestApproval",
providerThreadId: "t1",
turnId: "turn-1",
+ scope: "active_turn",
payload: {
kind: "approval",
subject: {
@@ -93,6 +94,7 @@ describe("decodeCodexInteractiveRequest", () => {
method: "item/commandExecution/requestApproval",
providerThreadId: "t1",
turnId: "turn-1",
+ scope: "active_turn",
payload: {
kind: "approval",
subject: {
@@ -379,6 +381,7 @@ describe("decodeCodexInteractiveRequest", () => {
method: "item/fileChange/requestApproval",
providerThreadId: "t1",
turnId: "turn-file-change",
+ scope: "active_turn",
payload: {
kind: "approval",
subject: {
@@ -417,6 +420,7 @@ describe("decodeCodexInteractiveRequest", () => {
method: "item/fileChange/requestApproval",
providerThreadId: "t1",
turnId: "turn-file-change",
+ scope: "active_turn",
payload: {
kind: "approval",
subject: {
@@ -455,6 +459,7 @@ describe("decodeCodexInteractiveRequest", () => {
method: "item/permissions/requestApproval",
providerThreadId: "t1",
turnId: "turn-permissions",
+ scope: "active_turn",
payload: {
kind: "approval",
subject: {
diff --git a/plugins/provider-codex/src/interactive-requests.ts b/plugins/provider-codex/src/interactive-requests.ts
index a78c0a1f52..556f3ebcee 100644
--- a/plugins/provider-codex/src/interactive-requests.ts
+++ b/plugins/provider-codex/src/interactive-requests.ts
@@ -119,6 +119,7 @@ export function decodeCodexInteractiveRequest(
method: request.method,
providerThreadId: parsed.data.threadId,
turnId: parsed.data.turnId,
+ scope: "active_turn",
payload: {
kind: "approval",
subject: {
@@ -161,6 +162,7 @@ export function decodeCodexInteractiveRequest(
method: request.method,
providerThreadId: parsed.data.threadId,
turnId: parsed.data.turnId,
+ scope: "active_turn",
payload: {
kind: "approval",
subject: {
@@ -192,6 +194,7 @@ export function decodeCodexInteractiveRequest(
method: request.method,
providerThreadId: parsed.data.threadId,
turnId: parsed.data.turnId,
+ scope: "active_turn",
payload: {
kind: "approval",
subject: {
diff --git a/tests/integration/fake/smoke/timeline-response.test.ts b/tests/integration/fake/smoke/timeline-response.test.ts
index 64dce3699c..ef823a61d7 100644
--- a/tests/integration/fake/smoke/timeline-response.test.ts
+++ b/tests/integration/fake/smoke/timeline-response.test.ts
@@ -49,6 +49,7 @@ function makeTimelineResponse(
activeThinking: null,
activeWorkflows: [],
activeBackgroundCommands: [],
+ extensionStates: [],
pendingTodos: null,
goal: null,
modelFallback: null,