blob: 485c131b1e992c742900000dbf2819fdc04256a0 [file]
import { execFile } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import { realpath, stat } from 'node:fs/promises';
import { promisify } from 'node:util';
import { homedir } from 'node:os';
import { resolve } from 'node:path';
import type { QueueEnqueueOutcome, SessionEvent } from '@maka/core/events';
import type { PermissionMode } from '@maka/core/permission';
import type { ExecutionBoundary, SandboxBoundaryResponse } from '@maka/core/sandbox-boundary';
import { executionBoundaryDisplayMode } from '@maka/core/sandbox-boundary';
import type { UserQuestionResponse } from '@maka/core/user-question';
import type {
BranchFromTurnInput,
CreateSessionInput,
TurnOrchestration,
UserMessageInput,
} from '@maka/core/runtime-inputs';
import type { OrchestrationMode } from '@maka/core/orchestration';
import type { SessionSummary, StoredMessage } from '@maka/core/session';
import { userFacingText } from '@maka/core/session';
import type { ThinkingLevel } from '@maka/core/model-thinking';
import type {
ContextDiagnostics,
RuntimeContinuation,
SafeBoundaryContinuationPlan,
} from '@maka/runtime';
import { DEFAULT_SESSION_NAME } from '@maka/core';
const execFileAsync = promisify(execFile);
export interface MakaSessionMoveResult {
previousCwd: string;
cwd: string;
changed: boolean;
oldCwdDirty?: boolean;
}
export type InspectCwdChanges = (cwd: string) => Promise<boolean | undefined>;
export interface MakaSessionRuntime {
createSession(input: CreateSessionInput): Promise<SessionSummary>;
listSessions(): Promise<SessionSummary[]>;
readExecutionBoundary(sessionId: string): Promise<ExecutionBoundary>;
getMessages(sessionId: string): Promise<StoredMessage[]>;
getContextDiagnostics?(sessionId: string): Promise<ContextDiagnostics>;
sendMessage(sessionId: string, input: UserMessageInput): AsyncIterable<SessionEvent>;
compactSession(sessionId: string, input?: { turnId?: string }): AsyncIterable<SessionEvent>;
planLatestAuthoritativeSafeBoundaryContinuation?(
sessionId: string,
): Promise<SafeBoundaryContinuationPlan>;
resumeSafeBoundaryContinuation?(continuation: RuntimeContinuation): AsyncIterable<SessionEvent>;
stopSession(sessionId: string, input?: { source?: 'stop_button' }): Promise<void>;
steer(sessionId: string, text: string): QueueEnqueueOutcome;
queueMessage(sessionId: string, text: string): QueueEnqueueOutcome;
drainFollowup(sessionId: string): string | null;
retractQueue(sessionId: string): string;
respondToSandboxBoundary(sessionId: string, response: SandboxBoundaryResponse): Promise<void>;
respondToUserQuestion?(sessionId: string, response: UserQuestionResponse): Promise<void>;
setPermissionMode(sessionId: string, mode: PermissionMode): Promise<SessionSummary>;
setOrchestrationMode(sessionId: string, mode: OrchestrationMode): Promise<SessionSummary>;
updateSession(
sessionId: string,
patch: {
cwd?: string;
model?: string;
llmConnectionSlug?: string;
thinkingLevel?: ThinkingLevel | undefined;
name?: string;
},
): Promise<SessionSummary>;
// Rewind reuses the runtime's branch primitives: a non-destructive copy of the
// transcript + RuntimeEvent ledger at a turn boundary, so resume correctness is
// inherited and the original session's log is left intact. `branchBeforeTurn`
// is the exclusive dual — it keeps everything strictly before the turn.
branchFromTurn(sessionId: string, input: BranchFromTurnInput): Promise<SessionSummary>;
branchBeforeTurn(sessionId: string, input: BranchFromTurnInput): Promise<SessionSummary>;
}
/** A turn the user can rewind to: its id plus a one-line label (its prompt). */
export interface RewindTarget {
turnId: string;
label: string;
}
/**
* A rewind result: the branched session's summary + messages (as with a switch),
* plus the chosen turn's full prompt so the caller can refill the editor for the
* user to edit and resend.
*/
export interface MakaSessionRewindResult extends MakaSessionSwitchResult {
prompt: string;
}
export interface MakaSessionDriverInput {
runtime: MakaSessionRuntime;
cwd: string;
llmConnectionSlug: string;
model: string;
permissionMode?: PermissionMode;
orchestrationMode?: OrchestrationMode;
newId?: () => string;
inspectCwdChanges?: InspectCwdChanges;
}
export interface MakaSessionSwitchResult {
summary: SessionSummary;
messages: StoredMessage[];
}
export interface MakaPreparedSessionTurn {
sessionId: string;
turnId: string;
events: AsyncIterable<SessionEvent>;
}
export interface MakaPreparePromptOptions {
/** Caller-owned identity used when Goal admission reserves a turn synchronously. */
turnId?: string;
/** Model-facing text when it differs from the prompt shown to the user. */
modelText?: string;
/** Trusted per-turn orchestration override; never encoded into prompt text. */
turnOrchestration?: TurnOrchestration;
}
export interface MakaSessionDriver {
listSessions(): Promise<SessionSummary[]>;
getSessionResumeAvailability?(session: SessionSummary): Promise<SessionResumeAvailability>;
/**
* Prepare a turn without consuming its event stream. `prompt` remains the
* human-facing text; a distinct `modelText` is persisted with `displayText`.
*/
preparePrompt(
prompt: string,
options?: MakaPreparePromptOptions,
): Promise<MakaPreparedSessionTurn>;
compactSession(): AsyncIterable<SessionEvent>;
resumeLatest?(): AsyncIterable<SessionEvent>;
/**
* Queue the text for mid-turn injection at the next step boundary. Returns
* `fallback` when there is no active run (the turn just ended); the caller
* should open a fresh turn with the text instead so it is never dropped.
* Optional so existing driver stubs need not implement the steering surface.
*/
steer?(text: string): QueueEnqueueOutcome;
/** Queue the text to open the turn after the current one finishes. */
queueMessage?(text: string): QueueEnqueueOutcome;
/** Drain the followup queue into one `\n\n`-joined prompt, or null if empty. */
takePendingFollowup?(): string | null;
/** Take back every queued message as one `\n\n`-joined string (clears both queues). */
retractQueued?(): string;
respondToSandboxBoundary(response: SandboxBoundaryResponse): Promise<void>;
respondToUserQuestion?(response: UserQuestionResponse): Promise<void>;
/**
* Switch the active session's model, optionally rebinding it to another
* connection at the same time (cross-provider `/model`). The next turn builds
* a fresh backend on the new connection.
*/
setModel(model: string, connectionSlug?: string): Promise<void>;
setThinkingLevel(level: ThinkingLevel | undefined): Promise<void>;
setPermissionMode(mode: PermissionMode): Promise<void>;
/** Available on Runtime-backed drivers; optional for lightweight host adapters. */
setOrchestrationMode?(mode: OrchestrationMode): Promise<void>;
renameSession(name: string): Promise<string | void>;
moveSession?(cwd: string): Promise<MakaSessionMoveResult>;
switchSession(sessionId: string): Promise<MakaSessionSwitchResult>;
/** Every prompted turn the user can rewind to, newest first. */
listRewindTargets(): Promise<RewindTarget[]>;
/**
* Rewind to a turn: branch the session to the state just *before* that turn
* (discarding it and everything after), switch onto the branch, and return the
* turn's prompt so the caller can refill the editor for an edit-and-resend.
*/
rewindToTurn(turnId: string): Promise<MakaSessionRewindResult>;
/** Abandon the active session so the next prompt starts a fresh one. */
startNewSession(): void;
stop(): Promise<void>;
getSessionId(): string | null;
getContextDiagnostics?(): Promise<ContextDiagnostics>;
getOrchestrationMode?(): OrchestrationMode;
/**
* The permission mode the ACTIVE session should be presented as, derived
* from its authoritative execution boundary (#1611). Available on
* Runtime-backed drivers; hosts without a boundary to read fall back to the
* summary they already hand the caller.
*/
getPermissionMode?(): PermissionMode;
}
export type SessionResumeAvailability = { available: true } | { available: false; reason: string };
const MISSING_SESSION_CWD_REASON = 'Missing working directory';
const DELETED_SESSION_CWD_REASON = 'Working directory no longer exists';
export async function inspectSessionResumeAvailability(
session: SessionSummary,
): Promise<SessionResumeAvailability> {
if (!session.cwd) return { available: false, reason: MISSING_SESSION_CWD_REASON };
try {
await realpath(session.cwd);
return { available: true };
} catch (error) {
const code = (error as { code?: unknown }).code;
if (code === 'ENOENT' || code === 'ENOTDIR') {
return { available: false, reason: DELETED_SESSION_CWD_REASON };
}
throw error;
}
}
export function createMakaSessionDriver(input: MakaSessionDriverInput): MakaSessionDriver {
return new RuntimeMakaSessionDriver(input);
}
class RuntimeMakaSessionDriver implements MakaSessionDriver {
private sessionId: string | null = null;
private cwd: string;
private model: string;
// The connection the active/next session runs on. Mutable so a cross-provider
// /model switch can rebind it; new sessions are created on this connection.
private llmConnectionSlug: string;
private thinkingLevel: ThinkingLevel | undefined;
/** The mode the NEXT created session is given. */
private permissionMode: PermissionMode;
/**
* How the ACTIVE session must be presented (#1611): derived from its
* execution boundary, which is the authority on what it may actually do.
* Kept apart from `permissionMode` on purpose — a resumed read-only session
* must read as read-only without making read-only the default for the next
* session started with `/new`.
*/
private activeBoundaryDisplayMode: PermissionMode | undefined;
private orchestrationMode: OrchestrationMode;
private readonly newId: () => string;
constructor(private readonly input: MakaSessionDriverInput) {
this.newId = input.newId ?? randomUUID;
this.cwd = input.cwd;
this.model = input.model;
this.llmConnectionSlug = input.llmConnectionSlug;
this.permissionMode = input.permissionMode ?? 'ask';
this.orchestrationMode = input.orchestrationMode ?? 'default';
}
async preparePrompt(
prompt: string,
options: MakaPreparePromptOptions = {},
): Promise<MakaPreparedSessionTurn> {
const sessionId = await this.ensureSession();
const turnId = options.turnId ?? this.newId();
const modelText = options.modelText ?? prompt;
const events = this.input.runtime.sendMessage(sessionId, {
turnId,
text: modelText,
...(modelText !== prompt ? { displayText: prompt } : {}),
...(options.turnOrchestration ? { turnOrchestration: options.turnOrchestration } : {}),
});
return {
sessionId,
turnId,
events,
};
}
async *compactSession(): AsyncIterable<SessionEvent> {
if (!this.sessionId) throw new Error('Cannot compact before a session starts.');
yield* this.input.runtime.compactSession(this.sessionId, { turnId: this.newId() });
}
async getContextDiagnostics(): Promise<ContextDiagnostics> {
if (!this.sessionId) return { status: 'unavailable', reason: 'no_completed_request' };
const read = this.input.runtime.getContextDiagnostics;
return read
? read.call(this.input.runtime, this.sessionId)
: { status: 'unavailable', reason: 'trace_unavailable' };
}
async *resumeLatest(): AsyncIterable<SessionEvent> {
if (!this.sessionId) throw new Error('Cannot resume before a session starts.');
const planLatest = this.input.runtime.planLatestAuthoritativeSafeBoundaryContinuation;
const resume = this.input.runtime.resumeSafeBoundaryContinuation;
if (!planLatest || !resume)
throw new Error('Safe-boundary resume is unavailable on this runtime.');
const plan = await planLatest.call(this.input.runtime, this.sessionId);
if (plan.disposition !== 'continue' || !plan.continuation) {
const detail =
plan.diagnostics.map((diagnostic) => diagnostic.message).join('; ') ||
plan.rejectionReasons.join(', ') ||
'no safe continuation candidate exists';
throw new Error(`Safe-boundary resume parked: ${detail}`);
}
yield* resume.call(this.input.runtime, plan.continuation);
}
async listSessions(): Promise<SessionSummary[]> {
return (await this.input.runtime.listSessions())
.map((session, index) => ({ session, index }))
.sort((left, right) => {
const cwdDelta = cwdRank(left.session, this.cwd) - cwdRank(right.session, this.cwd);
return cwdDelta !== 0 ? cwdDelta : left.index - right.index;
})
.map(({ session }) => session);
}
async getSessionResumeAvailability(session: SessionSummary): Promise<SessionResumeAvailability> {
return inspectSessionResumeAvailability(session);
}
async stop(): Promise<void> {
if (!this.sessionId) return;
await this.input.runtime.stopSession(this.sessionId, { source: 'stop_button' });
}
steer(text: string): QueueEnqueueOutcome {
if (!this.sessionId) return { kind: 'fallback' };
return this.input.runtime.steer(this.sessionId, text);
}
queueMessage(text: string): QueueEnqueueOutcome {
if (!this.sessionId) return { kind: 'fallback' };
return this.input.runtime.queueMessage(this.sessionId, text);
}
takePendingFollowup(): string | null {
if (!this.sessionId) return null;
return this.input.runtime.drainFollowup(this.sessionId);
}
retractQueued(): string {
if (!this.sessionId) return '';
return this.input.runtime.retractQueue(this.sessionId);
}
async respondToSandboxBoundary(response: SandboxBoundaryResponse): Promise<void> {
if (!this.sessionId) throw new Error('Cannot respond to permission before a session starts.');
await this.input.runtime.respondToSandboxBoundary(this.sessionId, response);
}
async respondToUserQuestion(response: UserQuestionResponse): Promise<void> {
if (!this.sessionId)
throw new Error('Cannot respond to a user question before a session starts.');
if (!this.input.runtime.respondToUserQuestion)
throw new Error('User questions are unavailable on this runtime.');
await this.input.runtime.respondToUserQuestion(this.sessionId, response);
}
async setModel(model: string, connectionSlug?: string): Promise<void> {
// Only rebind the connection when a different one is asked for; a same-slug
// /model is a plain model change and must not churn the backend needlessly.
const nextConnection =
connectionSlug && connectionSlug !== this.llmConnectionSlug ? connectionSlug : undefined;
if (this.sessionId) {
// Switching model (or connection) clears the per-model thinking variant.
const summary = await this.input.runtime.updateSession(this.sessionId, {
model,
thinkingLevel: undefined,
...(nextConnection ? { llmConnectionSlug: nextConnection } : {}),
});
this.model = summary.model;
this.llmConnectionSlug = summary.llmConnectionSlug;
this.thinkingLevel = summary.thinkingLevel;
return;
}
this.model = model;
if (nextConnection) this.llmConnectionSlug = nextConnection;
this.thinkingLevel = undefined;
}
async setThinkingLevel(level: ThinkingLevel | undefined): Promise<void> {
if (this.sessionId) {
const summary = await this.input.runtime.updateSession(this.sessionId, {
thinkingLevel: level,
});
this.thinkingLevel = summary.thinkingLevel;
return;
}
this.thinkingLevel = level;
}
async setPermissionMode(mode: PermissionMode): Promise<void> {
if (this.sessionId) {
const summary = await this.input.runtime.setPermissionMode(this.sessionId, mode);
this.permissionMode = summary.permissionMode;
// An explicit switch replaces the boundary, so re-derive rather than
// assume the requested mode landed exactly as asked.
this.activeBoundaryDisplayMode = executionBoundaryDisplayMode(
await this.input.runtime.readExecutionBoundary(this.sessionId),
);
return;
}
this.permissionMode = mode;
}
async setOrchestrationMode(mode: OrchestrationMode): Promise<void> {
if (this.sessionId) {
const summary = await this.input.runtime.setOrchestrationMode(this.sessionId, mode);
this.orchestrationMode = summary.orchestrationMode ?? mode;
return;
}
this.orchestrationMode = mode;
}
async renameSession(name: string): Promise<string> {
if (!this.sessionId) throw new Error('Cannot rename before a session starts.');
return (await this.input.runtime.updateSession(this.sessionId, { name })).name;
}
async moveSession(rawCwd: string): Promise<MakaSessionMoveResult> {
if (!this.sessionId) throw new Error('Cannot move before a session starts.');
const nextCwd = await resolveMoveCwd(rawCwd, this.cwd);
const previousCwd = this.cwd;
if (nextCwd === previousCwd) {
return { previousCwd, cwd: nextCwd, changed: false, oldCwdDirty: false };
}
const inspectCwdChanges = this.input.inspectCwdChanges ?? inspectGitCwdChanges;
const oldCwdDirty = await inspectCwdChanges(previousCwd).catch(() => undefined);
const summary = await this.input.runtime.updateSession(this.sessionId, {
cwd: nextCwd,
});
this.cwd = summary.cwd ?? nextCwd;
return { previousCwd, cwd: this.cwd, changed: true, oldCwdDirty };
}
async switchSession(sessionId: string): Promise<MakaSessionSwitchResult> {
const summary = (await this.listSessions()).find((session) => session.id === sessionId);
if (!summary) throw new Error(`Session not found: ${sessionId}`);
const availability = await inspectSessionResumeAvailability(summary);
if (!availability.available) {
if (!summary.cwd) throw new Error('Session has no working directory and cannot be resumed.');
throw new Error(`Session cwd no longer exists: ${summary.cwd}`);
}
const sessionCwd = summary.cwd!;
const boundary = await this.input.runtime.readExecutionBoundary(summary.id);
if (boundary.kind === 'external') {
throw new Error(
`Cannot resume externally isolated session ${summary.id} outside its owning harness.`,
);
}
// #1611: the boundary — not the stored header mode — decides how this
// session is presented, and the summary is left exactly as the runtime
// returned it. Overwriting `summary.permissionMode` here used to label a
// read-only session "Auto", which the picker then marked as the current
// option; choosing that "current" option silently replaced the read-only
// boundary with a writable one.
const displayMode = executionBoundaryDisplayMode(boundary);
const messages = await this.input.runtime.getMessages(summary.id);
this.sessionId = summary.id;
this.cwd = sessionCwd;
this.model = summary.model;
this.llmConnectionSlug = summary.llmConnectionSlug;
this.thinkingLevel = summary.thinkingLevel;
this.activeBoundaryDisplayMode = displayMode;
// New sessions started after this one still default to a normal mode.
this.permissionMode = boundary.kind === 'bypass' ? 'bypass' : 'ask';
this.orchestrationMode = summary.orchestrationMode ?? 'default';
return { summary, messages };
}
async listRewindTargets(): Promise<RewindTarget[]> {
if (!this.sessionId) return [];
const messages = await this.input.runtime.getMessages(this.sessionId);
// One target per turn that has a user prompt, in send order. The prompt text
// is the label — it is what the user recognizes a turn by. Rewinding to a
// turn resets to just *before* it (see rewindToTurn), so the latest turn is
// itself a valid target (undo it, edit its prompt, resend) and no turn is
// excluded. Turns with no user prompt (e.g. a /compact turn) never appear.
const promptByTurn = new Map<string, string>();
const order: string[] = [];
for (const message of messages) {
if (message.type !== 'user' || promptByTurn.has(message.turnId)) continue;
promptByTurn.set(message.turnId, userFacingText(message));
order.push(message.turnId);
}
return order
.reverse()
.map((turnId) => ({ turnId, label: firstLine(promptByTurn.get(turnId) ?? '') }));
}
async rewindToTurn(turnId: string): Promise<MakaSessionRewindResult> {
if (!this.sessionId) throw new Error('Cannot rewind before a session starts.');
// Read the turn's prompt from the *original* session before branching — the
// branch drops this turn, so it must be captured first. The full text (not
// the one-line label) is refilled into the editor for an edit-and-resend.
const messages = await this.input.runtime.getMessages(this.sessionId);
const userMessage = messages.find(
(message): message is Extract<StoredMessage, { type: 'user' }> =>
message.type === 'user' && message.turnId === turnId,
);
if (userMessage === undefined)
throw new Error(`Cannot rewind to turn ${turnId}: no user prompt.`);
const prompt = userFacingText(userMessage);
// Branch to the state just before the turn (copies transcript + ledger up to,
// but not including, it), then switch onto the branch. switchSession
// re-validates folder/connection and loads the branched messages, so the
// branch inherits the same resume guarantees as any resumed session and the
// original session — including the discarded turn — is left untouched.
const branch = await this.input.runtime.branchBeforeTurn(this.sessionId, {
sourceTurnId: turnId,
});
return { ...(await this.switchSession(branch.id)), prompt };
}
startNewSession(): void {
// Drop the active session id only. The current model / thinking / permission
// stay put, so the next prompt lazily creates a fresh session that inherits
// them (via ensureSession). The old session is left intact on disk.
this.sessionId = null;
// The previous session's boundary says nothing about the next one.
this.activeBoundaryDisplayMode = undefined;
}
getSessionId(): string | null {
return this.sessionId;
}
getOrchestrationMode(): OrchestrationMode {
return this.orchestrationMode;
}
getPermissionMode(): PermissionMode {
return this.activeBoundaryDisplayMode ?? this.permissionMode;
}
private async ensureSession(): Promise<string> {
if (this.sessionId) return this.sessionId;
const session = await this.input.runtime.createSession({
cwd: this.cwd,
name: DEFAULT_SESSION_NAME,
backend: 'ai-sdk',
llmConnectionSlug: this.llmConnectionSlug,
model: this.model,
permissionMode: this.permissionMode,
...(this.orchestrationMode !== 'default'
? { orchestrationMode: this.orchestrationMode }
: {}),
...(this.thinkingLevel !== undefined ? { thinkingLevel: this.thinkingLevel } : {}),
});
this.sessionId = session.id;
return session.id;
}
}
async function resolveMoveCwd(rawCwd: string, currentCwd: string): Promise<string> {
const input = rawCwd.trim();
if (!input) throw new Error('Working directory cannot be empty.');
const unquoted =
input.length >= 2 &&
((input.startsWith('"') && input.endsWith('"')) ||
(input.startsWith("'") && input.endsWith("'")))
? input.slice(1, -1)
: input;
const expanded =
unquoted === '~'
? homedir()
: unquoted.startsWith('~/')
? resolve(homedir(), unquoted.slice(2))
: unquoted;
const candidate = resolve(currentCwd, expanded);
let canonical: string;
try {
canonical = await realpath(candidate);
} catch (error) {
const code = (error as { code?: unknown }).code;
if (code === 'ENOENT' || code === 'ENOTDIR') {
throw new Error(`Working directory does not exist: ${candidate}`);
}
throw error;
}
const details = await stat(canonical);
if (!details.isDirectory()) throw new Error(`Working directory is not a directory: ${canonical}`);
return canonical;
}
async function inspectGitCwdChanges(cwd: string): Promise<boolean | undefined> {
try {
const result = await execFileAsync(
'git',
['-c', 'core.fsmonitor=false', 'status', '--porcelain=v1', '--untracked-files=normal'],
{
cwd,
env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' },
encoding: 'utf8',
maxBuffer: 1024 * 1024,
timeout: 5_000,
},
);
return result.stdout.trim().length > 0;
} catch {
// A non-git directory, inaccessible repository, or a git timeout should
// never prevent a successful move. The warning is best-effort by design.
return undefined;
}
}
function cwdRank(session: SessionSummary, cwd: string): number {
return session.cwd === cwd ? 0 : 1;
}
/** First non-empty line of a prompt, for a compact rewind-target label. */
function firstLine(text: string): string {
const line = text
.split('\n')
.map((part) => part.trim())
.find((part) => part.length > 0);
return line ?? '(empty prompt)';
}