| /* |
| * 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 { createInterface } from 'node:readline/promises'; |
| import { SessionActivityRegistry } from '@maka/runtime/goal-turn-lifecycle'; |
| import { readRuntimeHostConnectionCatalog } 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, |
| resolveRuntimeHostCliConflictDecision, |
| RuntimeHostCliConflictError, |
| } from './runtime-host-cli-context.js'; |
| import { resolveRuntimeHostNpmGlobalInstallation } from './runtime-host-cli-installation.js'; |
| import { restartRuntimeHostNpmGlobalDeployment } from './runtime-host-local-handoff.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 type { MakaSessionDriver } from './session-driver.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 foreignSessions = createForeignSessionStore(); |
| const contextInput = { |
| 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 createTuiContextWithHostConflictPrompt(contextInput); |
| if (!context) return 1; |
| } catch (error) { |
| 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); |
| } |
| 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.connectionSlug === context.connectionSlug) |
| .map((choice) => choice.model), |
| modelChoices: context.modelChoices, |
| connectionSlug: context.connectionSlug, |
| providerType: context.providerType, |
| 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, |
| ...(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`); |
| return 0; |
| } finally { |
| await context.driver.cleanupOwnedSideConversations().catch(() => undefined); |
| await context.close(); |
| } |
| } |
| |
| async function createTuiContextWithHostConflictPrompt( |
| input: Parameters<typeof createRuntimeHostTuiContext>[0], |
| ): Promise<Awaited<ReturnType<typeof createRuntimeHostTuiContext>> | null> { |
| const blockedRestartEpochs = new Set<string>(); |
| while (true) { |
| try { |
| return await createRuntimeHostTuiContext(input); |
| } catch (error) { |
| if (!(error instanceof RuntimeHostCliConflictError) || !process.stdin.isTTY) throw error; |
| process.stderr.write(`${error.message}\n`); |
| const canRestart = |
| error.registration.lifecycleMode === 'ephemeral' && |
| !blockedRestartEpochs.has(error.registration.hostEpoch) && |
| (await isPersistentNpmGlobalCli()); |
| const readline = createInterface({ input: process.stdin, output: process.stderr }); |
| let decision; |
| try { |
| const answer = await readline.question( |
| canRestart |
| ? 'Restart this local Host if it is idle, wait for it to exit, or cancel? [r/w/C] ' |
| : 'Wait only if the existing Host is expected to exit, or cancel? [w/C] ', |
| ); |
| decision = resolveRuntimeHostCliConflictDecision(answer, canRestart); |
| } finally { |
| readline.close(); |
| } |
| if (decision === 'cancel') return null; |
| if (decision === 'restart') { |
| const result = await restartRuntimeHostNpmGlobalDeployment({ |
| rootPath: input.rootPath, |
| registration: error.registration, |
| }); |
| if (result.kind === 'completed') continue; |
| if (result.kind === 'active_work') { |
| blockedRestartEpochs.add(error.registration.hostEpoch); |
| process.stderr.write( |
| 'The existing Runtime Host still owns active or durable work and was not interrupted.\n', |
| ); |
| continue; |
| } |
| if (result.kind === 'operator_required') { |
| blockedRestartEpochs.add(error.registration.hostEpoch); |
| continue; |
| } |
| if (result.kind === 'rejected') continue; |
| throw new Error(`Local Runtime Host restart requires recovery at ${result.phase}`, { |
| cause: result.cause, |
| }); |
| } |
| await waitForHostRetry(); |
| } |
| } |
| } |
| |
| async function isPersistentNpmGlobalCli(): Promise<boolean> { |
| try { |
| await resolveRuntimeHostNpmGlobalInstallation(); |
| return true; |
| } catch { |
| return false; |
| } |
| } |
| |
| function waitForHostRetry(): Promise<void> { |
| return new Promise((resolve) => setTimeout(resolve, 2_000)); |
| } |
| |
| async function runFirstRunOnboarding( |
| clientDataRoot: string, |
| rootPath: string, |
| cwd: string, |
| locale: UiLocale, |
| hostProfileId?: string, |
| ): Promise<boolean> { |
| const connected = await connectRuntimeHostCli({ |
| clientDataRoot, |
| rootPath, |
| interactiveSsh: true, |
| ...(hostProfileId ? { profileId: hostProfileId } : {}), |
| }); |
| try { |
| await runMakaPiTui({ |
| driver: createFirstRunSessionDriver(), |
| title: 'Maka', |
| cwd, |
| locale, |
| model: '', |
| connectionSlug: '', |
| permissionMode: 'ask', |
| firstRun: true, |
| turnActivity: { |
| activities: new SessionActivityRegistry(), |
| } satisfies MakaPiTuiTurnActivitySurface, |
| onboarding: createRuntimeHostOnboardingSurface(connected.connection), |
| }); |
| return (await readRuntimeHostConnectionCatalog(connected.connection)).defaultTarget !== null; |
| } 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'; |
| } |