Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/app/.ladle/model-picker-query-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ const STORY_PROVIDER_INFOS: ProviderInfo[] = STORY_PROVIDER_OPTIONS.map(
supportsServiceTier: STORY_SERVICE_TIER_SUPPORT[provider.value] ?? false,
supportsUserQuestion: true,
supportsFork: true,
supportsSessionImport: false,
supportedPermissionModes: [...supportedPermissionModes],
},
}),
Expand Down
3 changes: 3 additions & 0 deletions apps/app/src/hooks/useThreadCreationOptions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ function executionOptionsResponse(): SystemExecutionOptionsResponse {
supportsServiceTier: true,
supportsUserQuestion: true,
supportsFork: true,
supportsSessionImport: false,
supportedPermissionModes: ["accept-edits", "auto", "full"],
},
},
Expand All @@ -53,6 +54,7 @@ function executionOptionsResponse(): SystemExecutionOptionsResponse {
supportsServiceTier: true,
supportsUserQuestion: true,
supportsFork: true,
supportsSessionImport: false,
supportedPermissionModes: ["accept-edits", "auto", "full"],
},
},
Expand Down Expand Up @@ -104,6 +106,7 @@ function claudeExecutionOptionsResponse(): SystemExecutionOptionsResponse {
supportsServiceTier: true,
supportsUserQuestion: true,
supportsFork: true,
supportsSessionImport: false,
supportedPermissionModes: ["accept-edits", "auto", "full"],
},
},
Expand Down
86 changes: 86 additions & 0 deletions apps/cli/src/commands/thread/import.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { Command } from "commander";
import { threadVisibilitySchema, type Thread } from "@bb/domain";
import { action } from "../../action.js";
import { createCliBbSdk } from "../../client.js";
import { outputJson, prependErrorContext } from "../helpers.js";
import { parsePermissionMode, PERMISSION_MODE_HELP } from "./helpers.js";

interface ThreadImportCommandOptions {
project: string;
provider: string;
providerSession: string;
host?: string;
cwd: string;
json?: boolean;
permissionMode?: string;
title?: string;
visibility?: string;
}

export function registerImportCommand(
parent: Command,
getUrl: () => string,
): void {
parent
.command("import")
.description(
"Import an existing external ACP provider session as a thread",
)
.requiredOption("--project <id>", "Project the imported thread belongs to")
.requiredOption(
"--provider <acp-provider>",
'ACP provider that owns the session (e.g. "acp-omp")',
)
.requiredOption(
"--provider-session <external-session-id>",
"External provider session ID to import",
)
.option("--host <id>", "Host the session lives on (default: primary host)")
.requiredOption(
"--cwd <path>",
"Working directory the session ran in; must match the project source " +
"path or an existing workspace of the project",
)
.option("--title <title>", "Thread title")
.option("--permission-mode <mode>", PERMISSION_MODE_HELP)
.option("--visibility <visibility>", "Thread visibility: visible or hidden")
.option("--json", "Print machine-readable JSON output")
.action(
action(async (opts: ThreadImportCommandOptions) => {
const permissionMode = parsePermissionMode(opts.permissionMode);
const visibility =
opts.visibility === undefined
? undefined
: threadVisibilitySchema.parse(opts.visibility);

let thread: Thread;
try {
thread = await createCliBbSdk(getUrl()).threads.import({
projectId: opts.project,
providerId: opts.provider,
providerSessionId: opts.providerSession,
origin: "cli",
...(opts.host === undefined ? {} : { hostId: opts.host }),
cwd: opts.cwd,
...(opts.title === undefined ? {} : { title: opts.title }),
...(permissionMode === undefined ? {} : { permissionMode }),
...(visibility === undefined ? {} : { visibility }),
});
} catch (error: unknown) {
throw prependErrorContext(
`Failed to import provider session ${opts.providerSession}`,
error,
);
}

if (outputJson(opts, thread)) return;
console.log(`Thread imported: ${thread.id}`);
console.log(`Provider: ${opts.provider}`);
console.log(`Provider session: ${opts.providerSession}`);
console.log(`Status: ${thread.status}`);
if (thread.visibility === "hidden") {
console.log("Visibility: hidden");
}
}),
);
}
2 changes: 2 additions & 0 deletions apps/cli/src/commands/thread/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { registerOrganizationCommands } from "./organization.js";
import { registerShowCommand } from "./show.js";
import { registerSpawnCommand } from "./spawn.js";
import { registerForkCommand } from "./fork.js";
import { registerImportCommand } from "./import.js";
import { registerWaitCommand } from "./wait.js";

export function registerThreadCommands(
Expand All @@ -18,6 +19,7 @@ export function registerThreadCommands(
registerWaitCommand(thread, getUrl);
registerSpawnCommand(thread, getUrl);
registerForkCommand(thread, getUrl);
registerImportCommand(thread, getUrl);
registerListCommand(thread, getUrl);
registerShowCommand(thread, getUrl);
registerOpenCommand(thread, getUrl);
Expand Down
2 changes: 2 additions & 0 deletions apps/host-daemon/src/command-dispatch-support.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export interface CommandDispatchOptions {
}) => Promise<{
models: AvailableModel[];
selectedOnlyModels: AvailableModel[];
supportsSessionImport?: boolean;
}>;
getProviderCliStatusForProvider?: (
providerId: string,
Expand Down Expand Up @@ -121,6 +122,7 @@ export async function defaultListModels(
): Promise<{
models: AvailableModel[];
selectedOnlyModels: AvailableModel[];
supportsSessionImport?: boolean;
}> {
const runtimeKey =
`${options.bridgeBundleDir ?? ""}` +
Expand Down
1 change: 1 addition & 0 deletions apps/host-daemon/src/command-handlers/thread.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@ export async function startThread(
disallowedTools: command.disallowedTools,
instructionMode: command.instructionMode,
...(command.fork ? { fork: command.fork } : {}),
...(command.sessionImport ? { sessionImport: command.sessionImport } : {}),
});
return result;
} catch (error) {
Expand Down
10 changes: 10 additions & 0 deletions apps/server/src/internal/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,11 @@ async function applyEventEffects(
try {
const event = entry.event;
if (event.type === "turn/started") {
// Replayed history from an imported provider session is persisted for
// display only; it must not drive thread lifecycle transitions.
if (event.historical) {
continue;
}
const turnId = requireThreadEventScopeTurnId({
type: event.type,
scope: event.scope,
Expand Down Expand Up @@ -401,6 +406,11 @@ async function applyEventEffects(
}

if (event.type === "turn/completed") {
// See turn/started above: imported-history frames complete with no
// turn-completion side effects.
if (event.historical) {
continue;
}
const turnId = requireThreadEventScopeTurnId({
type: event.type,
scope: event.scope,
Expand Down
6 changes: 6 additions & 0 deletions apps/server/src/routes/threads/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {
} from "../../services/threads/thread-lifecycle.js";
import { createThreadFromRequest } from "../../services/threads/thread-create.js";
import { createThreadForkFromRequest } from "../../services/threads/thread-fork.js";
import { createThreadImportFromRequest } from "../../services/threads/thread-import.js";
import { requireChildThreadsConfirmation } from "../../services/threads/child-thread-confirmation.js";
import {
toThreadListEntryResponses,
Expand Down Expand Up @@ -275,6 +276,11 @@ export function registerThreadBaseRoutes(app: Hono, deps: AppDeps): void {
return context.json(toThreadResponseFromThread(deps, { thread }), 201);
});

post(routes.import, async (context, payload) => {
const thread = await createThreadImportFromRequest(deps, payload);
return context.json(toThreadResponseFromThread(deps, { thread }), 201);
});

get(routes.get, (context, query) => {
const thread = requirePublicThread(deps.db, context.req.param("id"));
return context.json(
Expand Down
10 changes: 10 additions & 0 deletions apps/server/src/services/skills/builtin-skills/bb-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,16 @@ message agents, or inspect projects, providers, and environments.
creates an idle fork by default; add `--prompt`, select `--workspace
isolated|reuse`, or anchor with `--source-seq-end`. Permission mode inherits
the source thread unless explicitly overridden.
- Use `bb thread import --project <id> --provider <acp-provider>
--provider-session <external-session-id> --cwd <path>` to import an
existing external ACP agent session (for example an `omp acp` session) as a
bb thread. The agent must support ACP `session/load`; bb replays the
session's history into the thread timeline and the thread lands idle, ready
for follow-up turns. `--cwd` is required: it is the caller's assertion of
the working directory the session ran in (bb has no way to read this back
from the external session), and must match the project source path or an
existing workspace already attached to the project — anything else is
refused. Pass `--host` when the session lives on a non-primary machine.
- Pass `--visibility hidden` for background/plugin workers that should remain
out of sidebar organization without contributing unread/pending favicon
attention. `bb thread list` excludes them by
Expand Down
38 changes: 37 additions & 1 deletion apps/server/src/services/system/known-acp-agents.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { buildAcpProviderInfo } from "@bb/agent-providers";
import {
formatCustomAcpAgentProviderId,
type CustomAcpAgent,
} from "@bb/config/bb-app-managed-config";
import type { ProviderInfo } from "@bb/domain";
import type { HostDaemonAcpLaunchSpec } from "@bb/host-daemon-contract";
import {
normalizeHostDaemonAcpLaunchSpec,
type HostDaemonAcpLaunchSpec,
} from "@bb/host-daemon-contract";

export interface KnownAcpAgent extends HostDaemonAcpLaunchSpec {
id: string;
Expand Down Expand Up @@ -102,3 +109,32 @@ export function findKnownAcpAgentForProviderId(
): KnownAcpAgent | undefined {
return KNOWN_ACP_AGENTS.find((agent) => agent.id === providerId);
}

export function findCustomAcpAgentForProviderId(
customAcpAgents: readonly CustomAcpAgent[],
providerId: string,
): CustomAcpAgent | undefined {
return customAcpAgents.find(
(agent) => formatCustomAcpAgentProviderId(agent.id) === providerId,
);
}

/**
* Resolve the launch spec exactly as thread.start does: a configured custom
* ACP agent shadows a built-in known agent that shares its provider id, and
* falls back to the static KNOWN_ACP_AGENTS entry otherwise. Callers that
* need to probe or launch an ACP agent (thread start/resume/import) must
* share this resolution so what gets probed is what actually serves the
* thread.
*/
export function buildAcpLaunchSpecForProviderId(
customAcpAgents: readonly CustomAcpAgent[],
providerId: string,
): HostDaemonAcpLaunchSpec | undefined {
const agent = findCustomAcpAgentForProviderId(customAcpAgents, providerId);
if (agent) {
return normalizeHostDaemonAcpLaunchSpec(agent);
}
const knownAgent = findKnownAcpAgentForProviderId(providerId);
return knownAgent ? normalizeHostDaemonAcpLaunchSpec(knownAgent) : undefined;
}
5 changes: 3 additions & 2 deletions apps/server/src/services/threads/parent-system-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -332,9 +332,10 @@ async function queueReadyParentSystemMessage(

const command = await prepareReadyThreadTurnCommand(deps, {
thread: args.thread,
// A parent system message targets an already-started thread; forking only
// happens at create time.
// A parent system message targets an already-started thread; forking and
// session import only happen at create time.
fork: null,
sessionImport: null,
input: args.input,
requestId,
execution: args.execution,
Expand Down
54 changes: 17 additions & 37 deletions apps/server/src/services/threads/thread-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,6 @@ import {
getBuiltInAgentProviderInfo,
isAgentProviderId,
} from "@bb/agent-providers";
import {
formatCustomAcpAgentProviderId,
type CustomAcpAgent,
} from "@bb/config/bb-app-managed-config";
import {
DEFAULT_CLAUDE_CODE_MOCK_CLI_TRAFFIC_ENDPOINT,
type ClaudeCodeMockCliTrafficConfig,
Expand All @@ -28,11 +24,9 @@ import {
WorkspaceProvisionType,
promptInputHasCommandMention,
} from "@bb/domain";
import {
normalizeHostDaemonAcpLaunchSpec,
type HostDaemonAcpLaunchSpec,
type HostDaemonCommand,
type TurnSubmitTarget,
import type {
HostDaemonCommand,
TurnSubmitTarget,
} from "@bb/host-daemon-contract";
import type { AppDeps, LoggedWorkSessionDeps } from "../../types.js";
import type { CommandResultSideEffectsDeps } from "../../internal/command-result-side-effects.js";
Expand All @@ -42,7 +36,10 @@ import {
startLiveHostCommand,
} from "../hosts/live-command.js";
import { getLastProviderThreadId } from "./thread-events.js";
import type { ThreadForkDescriptor } from "./thread-provisioning-context.js";
import type {
ThreadForkDescriptor,
ThreadSessionImportDescriptor,
} from "./thread-provisioning-context.js";
import {
resolveThreadRuntimeCommandConfig,
type ResolvedThreadRuntimeCommandConfig,
Expand All @@ -56,7 +53,7 @@ import {
} from "./thread-execution-plan.js";
import { clampPermissionModeToHost } from "../hosts/permission-ceiling.js";
import { workspaceContextFromPath } from "../environments/workspace-command-target.js";
import { findKnownAcpAgentForProviderId } from "../system/known-acp-agents.js";
import { buildAcpLaunchSpecForProviderId } from "../system/known-acp-agents.js";

export type ExecutionOptionsRequest = ExistingThreadExecutionInputRequest;

Expand Down Expand Up @@ -91,6 +88,9 @@ export interface ThreadStartCommandArgs {
// Non-null ⇒ clone the parent's provider session at its branch point (native
// fork) instead of starting fresh. null ⇒ a normal start.
fork: ThreadForkDescriptor | null;
// Non-null ⇒ bind the new thread to this existing external provider session
// (ACP session import) and replay its history. null ⇒ a normal start.
sessionImport: ThreadSessionImportDescriptor | null;
permissionEscalation: PermissionEscalation;
input: PromptInput[];
inputGroups?: PromptInput[][];
Expand Down Expand Up @@ -195,30 +195,6 @@ function providerSupportsThreadArchiveForwarding(providerId: string): boolean {
return getBuiltInAgentProviderInfo(providerId).capabilities.supportsArchive;
}

function findCustomAcpAgentForProviderId(
customAcpAgents: CustomAcpAgent[],
providerId: string,
): CustomAcpAgent | undefined {
return customAcpAgents.find(
(agent) => formatCustomAcpAgentProviderId(agent.id) === providerId,
);
}

function buildAcpLaunchSpecForProviderId(
deps: Pick<AppDeps, "config">,
providerId: string,
): HostDaemonAcpLaunchSpec | undefined {
const agent = findCustomAcpAgentForProviderId(
deps.config.customAcpAgents,
providerId,
);
if (agent) {
return normalizeHostDaemonAcpLaunchSpec(agent);
}
const knownAgent = findKnownAcpAgentForProviderId(providerId);
return knownAgent ? normalizeHostDaemonAcpLaunchSpec(knownAgent) : undefined;
}

function resolveClaudeCodeMockCliTrafficConfig(
deps: Pick<AppDeps, "db">,
): ClaudeCodeMockCliTrafficConfig {
Expand Down Expand Up @@ -354,7 +330,10 @@ export async function buildThreadStartCommand(
environment: args.environment,
model: args.execution.model,
});
const acpLaunchSpec = buildAcpLaunchSpecForProviderId(deps, args.providerId);
const acpLaunchSpec = buildAcpLaunchSpecForProviderId(
deps.config.customAcpAgents,
args.providerId,
);
return {
type: "thread.start",
environmentId: args.environment.id,
Expand Down Expand Up @@ -391,14 +370,15 @@ export async function buildThreadStartCommand(
instructionMode: runtimeContext.instructionMode,
threadStoragePath: runtimeContext.threadStoragePath,
...(args.fork ? { fork: args.fork } : {}),
...(args.sessionImport ? { sessionImport: args.sessionImport } : {}),
};
}

function buildPreparedTurnSubmitCommandPayload(
args: PreparedTurnSubmitCommandBuildArgs,
): PreparedTurnSubmitCommandPayload {
const acpLaunchSpec = buildAcpLaunchSpecForProviderId(
args.deps,
args.deps.config.customAcpAgents,
args.runtimeContext.providerId,
);
return {
Expand Down
Loading