blob: f24d42f4261fd03439495a0b582b7f7d56822dab [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 { parseNoRealConnectionError } from '@maka/core/connection-error-copy';
import type { UiLocale } from '@maka/core/ui-locale';
import { SessionActivityRegistry } from '@maka/runtime/goal-turn-lifecycle';
import { HostHandoffCancelledError } from '@maka/runtime-host/client';
import { runtimeHostProfileUsesHostWorkspace } from '@maka/runtime-host/profile-kind';
import { createForeignSessionStore } from '@maka/storage/foreign-session-store';
import { formatMakaResumeHint } from './cli-invocation.js';
import {
connectRuntimeHostCli,
connectRuntimeHostCliConnection,
} from './runtime-host-cli-context.js';
import { createCliHostHandoffSurface } from './runtime-host-handoff-surface.js';
import { createRuntimeHostOnboardingSurface } from './runtime-host-onboarding.js';
import type { MakaPiTuiTurnActivitySurface } from './pi-tui-contracts.js';
import { runMakaPiTui } from './pi-tui-runner.js';
import { createRuntimeHostTuiContext } from './runtime-host-tui-context.js';
import { describeTuiHost, prepareTuiHostOwnerAction } from './runtime-host-tui-owner.js';
import type { MakaSessionDriver } from './session-driver.js';
import { getTuiHostOwnerCopy } from './tui-host-owner-copy.js';
export interface RunRuntimeHostTuiInput {
readonly cliCommand: string;
readonly clientDataRoot: string;
readonly workspaceRoot: string;
readonly cwd: string;
readonly locale: UiLocale;
readonly resumeSessionId?: string;
readonly resumeCwd?: string;
readonly hostProfileId?: string;
readonly projectId?: string;
readonly onProcessExit: (exitCode: number, error?: Error) => void;
}
export async function runRuntimeHostTui(input: RunRuntimeHostTuiInput): Promise<number> {
const ownerCopy = getTuiHostOwnerCopy(input.locale);
const foreignSessions = createForeignSessionStore();
const contextInput = {
...(process.stdin.isTTY ? { handoffSurface: createCliHostHandoffSurface(input.locale) } : {}),
clientDataRoot: input.clientDataRoot,
rootPath: input.workspaceRoot,
cwd: input.cwd,
...(input.resumeSessionId ? { resumeSessionId: input.resumeSessionId } : {}),
...(input.hostProfileId ? { hostProfileId: input.hostProfileId } : {}),
...(input.projectId ? { projectId: input.projectId } : {}),
};
let context;
try {
context = await createRuntimeHostTuiContext(contextInput);
} catch (error) {
if (error instanceof HostHandoffCancelledError) return 1;
if (!isMissingDefaultConnection(error) || input.resumeSessionId) throw error;
const configured = await runFirstRunOnboarding(
input.clientDataRoot,
input.workspaceRoot,
input.cwd,
input.locale,
input.hostProfileId,
);
if (!configured) throw error;
context = await createRuntimeHostTuiContext(contextInput);
}
let ownerAction: (() => Promise<number>) | undefined;
let contextOpen = true;
try {
await runMakaPiTui({
driver: context.driver,
title: runtimeHostProfileUsesHostWorkspace(context.profile.kind)
? `Maka — ${context.profile.name}`
: 'Maka',
cwd: context.cwd,
locale: input.locale,
model: context.model,
models: context.modelChoices
.filter(
(choice) =>
choice.connectionId === context.connectionId &&
choice.connectionSlug === context.connectionSlug,
)
.map((choice) => choice.model),
modelChoices: context.modelChoices,
subscribeModelCatalogChanges: context.subscribeModelCatalogChanges,
connectionSlug: context.connectionSlug,
connectionId: context.connectionId,
connectionIdentities: context.connectionIdentities,
modelContextWindow: context.modelContextWindow,
permissionMode: context.prospectivePermissionMode,
turnActivity: context.turnActivity,
listSkills: context.listSkills,
agentGraphHistory: context.agentGraphHistory,
onboarding: context.onboarding,
...(context.mcp ? { mcp: context.mcp } : {}),
recap: context.recap,
hostControl: {
status: () => describeTuiHost({ ...context, locale: input.locale }),
prepare: async (action, confirm) => {
if (ownerAction || !contextOpen) throw new Error(ownerCopy.pending);
const execute = await prepareTuiHostOwnerAction({
profile: context.profile,
connection: context.connection,
rootPath: input.workspaceRoot,
locale: input.locale,
action,
confirm,
});
if (!execute) return false;
if (!contextOpen || ownerAction) throw new Error(ownerCopy.disconnected);
ownerAction = execute;
return true;
},
},
...(runtimeHostProfileUsesHostWorkspace(context.profile.kind)
? {
sessionListScope: 'all' as const,
clientPathAuthority: 'none' as const,
}
: { foreignSessions }),
subscribeShellRunUpdates: (listener) => context.driver.subscribeShellRunUpdates(listener),
listShellRunUpdates: (sessionId) => context.driver.listShellRunUpdates(sessionId),
onProcessExit: input.onProcessExit,
cliCommand: input.cliCommand,
resumeSessionId: input.resumeSessionId,
resumeCwd: input.resumeCwd,
...(runtimeHostProfileUsesHostWorkspace(context.profile.kind) && input.resumeSessionId
? { resumeFailure: 'exit' as const }
: {}),
});
const sessionId = context.driver.getSessionId();
const hint = formatMakaResumeHint(input.cliCommand, sessionId, {
...(runtimeHostProfileUsesHostWorkspace(context.profile.kind)
? { hostProfileId: context.profile.id }
: {}),
});
if (hint) process.stdout.write(`${hint}\n`);
} finally {
contextOpen = false;
await context.driver.cleanupOwnedSideConversations().catch(() => undefined);
await context.close();
}
// No reconnecting client remains to respawn a stopped Host, and an updater
// can use the restored terminal without competing with the TUI renderer.
return ownerAction ? ownerAction() : 0;
}
async function runFirstRunOnboarding(
clientDataRoot: string,
rootPath: string,
cwd: string,
locale: UiLocale,
hostProfileId?: string,
): Promise<boolean> {
const connected = await connectRuntimeHostCli({
clientDataRoot,
rootPath,
interactiveSsh: true,
...(process.stdin.isTTY ? { handoffSurface: createCliHostHandoffSurface(locale) } : {}),
...(hostProfileId ? { profileId: hostProfileId } : {}),
});
const onboarding = createRuntimeHostOnboardingSurface(connected.connection, {
connectOAuth: (signal) =>
connectRuntimeHostCliConnection({
clientDataRoot,
rootPath,
profileId: connected.profile.id,
signal,
}),
});
try {
await runMakaPiTui({
driver: createFirstRunSessionDriver(),
title: 'Maka',
cwd,
locale,
model: '',
connectionSlug: '',
permissionMode: 'ask',
firstRun: true,
turnActivity: {
activities: new SessionActivityRegistry(),
} satisfies MakaPiTuiTurnActivitySurface,
onboarding,
});
// The overlay is closed: only the default-target metadata is needed, and
// an offline primary connection must not prevent the finally cleanup.
const catalog = await connected.connection.request(
'connection.catalog.query',
{ kind: 'start' },
1_000,
);
return catalog.kind === 'page' && catalog.defaultTarget !== null;
} finally {
try {
await onboarding.close();
} finally {
await connected.close();
}
}
}
function createFirstRunSessionDriver(): MakaSessionDriver {
const unavailable = async (): Promise<never> => {
throw new Error('First-run onboarding cannot start an agent turn');
};
return {
getSessionId: () => null,
listSessions: async () => [],
preparePrompt: unavailable,
submitMessage: unavailable,
queryCancelledMessages: async () => ({ cancelledMessageIds: [] }),
compactSession: async function* () {},
respondToSandboxBoundary: async () => {},
setModel: async () => {},
setThinkingLevel: async () => {},
setPermissionMode: async () => {},
renameSession: async () => {},
switchSession: unavailable,
listRewindTargets: async () => [],
rewindToTurn: unavailable,
startNewSession: () => Promise.resolve(),
stop: async () => {},
};
}
function isMissingDefaultConnection(error: unknown): boolean {
const parsed = parseNoRealConnectionError(error);
return parsed.matched && parsed.reason === 'missing_default_connection';
}