blob: 188a907f7327760f920099318b0285e32bb1c8cc [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { randomUUID } from 'node:crypto';
import { join } from 'node:path';
import type { PermissionMode } from '@maka/core/permission';
import {
createGenesisExecutionBoundary,
executionBoundaryDisplayMode,
} from '@maka/core/sandbox-boundary';
import { findProjectByIdentity } from '@maka/core/project';
import type {
RuntimeHostConnectionCatalogEntry as ConnectionCatalogEntry,
RuntimeHostConnectionCatalogSnapshot as ConnectionCatalogSnapshot,
OpenHostHandoffSurface,
} from '@maka/runtime-host/client';
import { SessionActivityRegistry } from '@maka/runtime/goal-turn-lifecycle';
import { type InvocableSkillEntry } from '@maka/runtime/skill-invocation';
import {
acquireProcessLifetimeOwner,
type ProcessLifetimeOwner,
} from '@maka/storage/process-lifetime-owner';
import {
readRuntimeHostAgentGraphEpochs,
readRuntimeHostConnectionCatalog,
readRuntimeHostInvocableSkills,
readRuntimeHostProjects,
isRuntimeHostReconnectingConnection,
type AgentGraphEpochDirectory,
type RuntimeHostConnection,
type RuntimeHostProfile,
} from '@maka/runtime-host/client';
import { runtimeHostProfileUsesHostWorkspace } from '@maka/runtime-host/profile-kind';
import type { AgentGraphClientSnapshot, WorkspaceTarget } from '@maka/runtime-host/protocol';
import {
connectRuntimeHostCli,
connectRuntimeHostCliConnection,
readHostChatDefaultPermissionMode,
resolveRuntimeHostCliTarget,
} from './runtime-host-cli-context.js';
import type {
ConnectionIdentity,
MakaPiTuiTurnActivitySurface,
ModelChoice,
SessionRecapGenerator,
} from './pi-tui-contracts.js';
import {
createRuntimeHostMakaSessionDriver,
type RuntimeHostMakaSessionDriverInput,
} from './runtime-host-session-driver.js';
import {
createRuntimeHostOnboardingSurface,
projectRuntimeHostConnectionIdentities,
projectRuntimeHostModelChoices,
} from './runtime-host-onboarding.js';
import {
createTuiMcpController,
type TuiMcpController,
type TuiMcpManagement,
} from './tui-mcp-control.js';
import { createRemoteTuiMcpPublicationTarget } from './tui-mcp-remote-publication.js';
export interface RuntimeHostTuiContext {
readonly connection: RuntimeHostConnection;
readonly driver: ReturnType<typeof createRuntimeHostMakaSessionDriver>;
readonly cwd: string;
readonly connectionSlug: string;
readonly connectionId?: string;
readonly connectionIdentities: readonly ConnectionIdentity[];
readonly connectionName: string;
readonly model: string;
readonly modelContextWindow?: number;
readonly modelChoices: readonly ModelChoice[];
/**
* The Host now resolves connection catalogs differently — it refreshed its
* models.dev catalog. Re-read and re-project rather than patching what is
* held: which models are offerable and what is true about them are both the
* Host's answers.
*/
readonly subscribeModelCatalogChanges: (
listener: (refresh: {
readonly modelChoices: readonly ModelChoice[];
readonly connectionIdentities: readonly ConnectionIdentity[];
}) => void,
) => () => void;
/**
* Mode a Session created right now would start in, for display only. The
* driver never receives it: an omitted create field is what lets the Host
* stay the authority, and this snapshot goes stale the moment another client
* changes the setting.
*/
readonly prospectivePermissionMode: PermissionMode;
readonly turnActivity: MakaPiTuiTurnActivitySurface;
readonly listSkills: (cwd: string) => Promise<readonly InvocableSkillEntry[]>;
readonly agentGraphHistory: {
listEpochs(rootSessionId: string): Promise<AgentGraphEpochDirectory>;
getSnapshot(rootSessionId: string, graphId: string): Promise<AgentGraphClientSnapshot>;
};
readonly recap: SessionRecapGenerator;
readonly onboarding: ReturnType<typeof createRuntimeHostOnboardingSurface>;
readonly mcp?: TuiMcpManagement;
readonly profile: RuntimeHostProfile;
close(): Promise<void>;
}
export interface CreateRuntimeHostTuiContextInput {
readonly handoffSurface?: OpenHostHandoffSurface;
readonly clientDataRoot: string;
readonly rootPath: string;
readonly cwd: string;
readonly resumeSessionId?: string;
readonly hostProfileId?: string;
readonly projectId?: string;
}
export async function createRuntimeHostTuiContext(
input: CreateRuntimeHostTuiContextInput,
): Promise<RuntimeHostTuiContext> {
const connected = await connectRuntimeHostCli({
clientDataRoot: input.clientDataRoot,
rootPath: input.rootPath,
interactiveSsh: true,
...(input.handoffSurface ? { handoffSurface: input.handoffSurface } : {}),
...(input.hostProfileId ? { profileId: input.hostProfileId } : {}),
});
const connection = connected.connection;
let mcp: TuiMcpController | undefined;
let sessionCopyCleanupOwner: ProcessLifetimeOwner | undefined;
try {
const catalog = connected.catalog;
const workspace = await resolveRuntimeHostTuiWorkspace(connection, connected.profile, input);
const selectedTarget = input.resumeSessionId
? await resolveResumeTarget(connection, catalog, input.resumeSessionId)
: exactTuiTarget(resolveTarget(catalog));
const modelChoices = projectRuntimeHostModelChoices(catalog);
// Display state, never a create input. Deriving it through the same
// boundary mapping every other surface uses keeps a prospective Session and
// a live one from ever labelling the same permissions differently.
const prospectivePermissionMode =
executionBoundaryDisplayMode(
createGenesisExecutionBoundary(await readHostChatDefaultPermissionMode(connection)),
) ?? 'ask';
const sessionCopyCleanupRoot = join(input.clientDataRoot, 'tui-session-copies');
const owner = await acquireProcessLifetimeOwner(
join(sessionCopyCleanupRoot, connection.rootId),
);
sessionCopyCleanupOwner = owner;
const driverInput: RuntimeHostMakaSessionDriverInput = {
connection,
cwd: input.cwd,
...(selectedTarget.connectionId === undefined
? {}
: { llmConnectionId: selectedTarget.connectionId }),
llmConnectionSlug: selectedTarget.connectionSlug,
model: selectedTarget.model,
prospectivePermissionMode,
sessionCopyCleanupRoot,
sessionCopyCleanupOwner: owner,
executionLocation: runtimeHostProfileUsesHostWorkspace(connected.profile.kind)
? { kind: 'host' }
: { kind: 'client_path' },
...(workspace ? { workspace } : {}),
};
const driver = createRuntimeHostMakaSessionDriver(driverInput);
await driver.recoverSideConversations();
if (connected.profile.kind === 'local') {
if (!isRuntimeHostReconnectingConnection(connection)) {
throw new Error('Local Runtime Host TUI connection is not reconnectable');
}
mcp = createTuiMcpController({
workspaceRoot: input.rootPath,
connection,
});
} else if (connected.profile.kind === 'remote') {
if (!connected.profileIncarnationId) {
throw new Error('Remote Runtime Host profile incarnation is unavailable');
}
mcp = createTuiMcpController({
workspaceRoot: input.rootPath,
connection: createRemoteTuiMcpPublicationTarget({
clientDataRoot: input.clientDataRoot,
profile: connected.profile,
profileIncarnationId: connected.profileIncarnationId,
ownerClientInstanceId: connected.clientInstanceId,
}),
});
}
// From the Host-resolved choice, not the connection's stored rows: a
// fallback or provider-default model exists only in the resolved catalog,
// so reading `models` left the very first status line and its diagnostics
// without a denominator until some later transition happened to refresh
// it. Every later read of this value already comes from `modelChoices`.
const modelContextWindow = modelChoices.find(
(choice) =>
choice.connectionSlug === selectedTarget.connectionSlug &&
choice.model === selectedTarget.model,
)?.contextWindow;
const onboarding = createRuntimeHostOnboardingSurface(connection, {
connectOAuth: (signal) =>
connectRuntimeHostCliConnection({
clientDataRoot: input.clientDataRoot,
rootPath: input.rootPath,
profileId: connected.profile.id,
signal,
}),
});
return {
connection,
driver,
cwd: input.cwd,
connectionSlug: selectedTarget.connectionSlug,
...(selectedTarget.connectionId === undefined
? {}
: { connectionId: selectedTarget.connectionId }),
connectionIdentities: projectRuntimeHostConnectionIdentities(catalog),
connectionName: selectedTarget.connection?.name ?? selectedTarget.connectionSlug,
model: selectedTarget.model,
...(modelContextWindow === undefined ? {} : { modelContextWindow }),
modelChoices,
subscribeModelCatalogChanges: (listener) =>
connection.subscribeConnectionCatalogChanges(() => {
void readRuntimeHostConnectionCatalog(connection)
.then((refreshed) =>
listener({
modelChoices: projectRuntimeHostModelChoices(refreshed),
connectionIdentities: projectRuntimeHostConnectionIdentities(refreshed),
}),
)
// A catalog that will not read leaves the choices the TUI already
// has. The Host announces again the next time it changes.
.catch(() => undefined);
}),
prospectivePermissionMode,
turnActivity: createHostOwnedTurnActivity(),
listSkills: (cwd) =>
listStablePresentedSkills(
connection,
driver.getSessionId(),
workspace ??
(runtimeHostProfileUsesHostWorkspace(connected.profile.kind)
? undefined
: { kind: 'host_path', path: cwd }),
driver.getPermissionMode?.() ?? prospectivePermissionMode,
),
agentGraphHistory: createRuntimeHostAgentGraphHistory(connection),
recap: createRuntimeHostRecapGenerator(connection),
onboarding,
...(mcp ? { mcp } : {}),
profile: connected.profile,
close: () => closeRuntimeHostTuiContext(onboarding, mcp, owner, connected.close),
};
} catch (error) {
await closeRuntimeHostTuiContext(
undefined,
mcp,
sessionCopyCleanupOwner,
connected.close,
).catch(() => undefined);
throw error;
}
}
async function closeRuntimeHostTuiContext(
onboarding: ReturnType<typeof createRuntimeHostOnboardingSurface> | undefined,
mcp: TuiMcpController | undefined,
sessionCopyCleanupOwner: ProcessLifetimeOwner | undefined,
closeConnection: () => Promise<void>,
): Promise<void> {
const errors: unknown[] = [];
try {
await onboarding?.close();
} catch (error) {
errors.push(error);
}
try {
await mcp?.close();
} catch (error) {
errors.push(error);
}
try {
await closeConnection();
} catch (error) {
errors.push(error);
}
try {
await sessionCopyCleanupOwner?.close();
} catch (error) {
errors.push(error);
}
if (errors.length === 1) throw errors[0];
if (errors.length > 1)
throw new AggregateError(errors, 'Unable to close Runtime Host TUI context');
}
function createRuntimeHostAgentGraphHistory(
connection: RuntimeHostConnection,
): RuntimeHostTuiContext['agentGraphHistory'] {
return {
listEpochs: (rootSessionId) => readRuntimeHostAgentGraphEpochs(connection, rootSessionId),
getSnapshot: (rootSessionId, graphId) =>
connection.request('agent.graph.query', { rootSessionId, graphId }),
};
}
function createRuntimeHostRecapGenerator(connection: RuntimeHostConnection): SessionRecapGenerator {
return {
generate: async (sessionId, reason) => {
try {
const result = await connection.request('session.recap.generate', {
sessionId,
effectId: randomUUID(),
reason,
});
return result.kind === 'generated'
? { ok: true, text: result.text, raw: result.raw }
: { ok: false, error: result.errorClass };
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : String(error) };
}
},
};
}
async function listStablePresentedSkills(
connection: RuntimeHostConnection,
sessionId: string | null,
fallbackWorkspace: WorkspaceTarget | undefined,
permissionMode: PermissionMode,
): Promise<InvocableSkillEntry[]> {
const workspace = sessionId
? await readSessionWorkspace(connection, sessionId, fallbackWorkspace)
: fallbackWorkspace;
if (!workspace) throw new Error('The remote Session workspace is unavailable');
return [
...(await readRuntimeHostInvocableSkills(connection, {
kind: 'new_session',
context: { workspace },
collaborationMode: 'agent',
permissionMode,
})),
];
}
export async function resolveRuntimeHostTuiWorkspace(
connection: RuntimeHostConnection,
profile: RuntimeHostProfile,
input: Pick<CreateRuntimeHostTuiContextInput, 'resumeSessionId' | 'projectId'>,
): Promise<WorkspaceTarget | undefined> {
if (input.resumeSessionId) return undefined;
if (!runtimeHostProfileUsesHostWorkspace(profile.kind)) {
return input.projectId ? { kind: 'project', projectId: input.projectId } : undefined;
}
if (!input.projectId) {
throw new Error(`Runtime Host profile ${profile.id} requires --project for a new Session`);
}
const project = findProjectByIdentity(await readRuntimeHostProjects(connection), input.projectId);
if (!project || project.archivedAt !== null || !project.available) {
throw new Error(`Runtime Host Project is unavailable: ${input.projectId}`);
}
return { kind: 'project', projectId: project.id };
}
async function readSessionWorkspace(
connection: RuntimeHostConnection,
sessionId: string,
fallback: WorkspaceTarget | undefined,
): Promise<WorkspaceTarget> {
const result = await connection.request('session.catalog.query', { kind: 'get', sessionId });
if (result.kind === 'session' && result.session && !('kind' in result.session)) {
return result.session.workspace.target;
}
if (fallback) return fallback;
throw new Error(`Runtime Host Session is unavailable: ${sessionId}`);
}
function resolveTarget(catalog: ConnectionCatalogSnapshot): {
connection: ConnectionCatalogEntry;
model: string;
} {
return resolveRuntimeHostCliTarget(catalog);
}
async function resolveResumeTarget(
connection: RuntimeHostConnection,
catalog: ConnectionCatalogSnapshot,
sessionId: string,
): Promise<ResolvedTuiTarget> {
const result = await connection.request('session.catalog.query', { kind: 'get', sessionId });
const session = result.kind === 'session' ? result.session : null;
if (session && !('kind' in session)) {
const sessionConnection = catalog.connections.find(
(candidate) =>
session.llmConnectionId !== null &&
candidate.connectionId === session.llmConnectionId &&
candidate.slug === session.llmConnectionSlug,
);
return {
...(session.llmConnectionId === null ? {} : { connectionId: session.llmConnectionId }),
connectionSlug: session.llmConnectionSlug,
model: session.model,
...(sessionConnection ? { connection: sessionConnection } : {}),
};
}
return exactTuiTarget(resolveTarget(catalog));
}
interface ResolvedTuiTarget {
readonly connectionId?: string;
readonly connectionSlug: string;
readonly model: string;
readonly connection?: ConnectionCatalogEntry;
}
function exactTuiTarget(target: {
readonly connection: ConnectionCatalogEntry;
readonly model: string;
}): ResolvedTuiTarget {
return {
connectionId: target.connection.connectionId,
connectionSlug: target.connection.slug,
model: target.model,
connection: target.connection,
};
}
function createHostOwnedTurnActivity(): MakaPiTuiTurnActivitySurface {
return { activities: new SessionActivityRegistry() };
}