import { configToArgs } from "./config.js"; import type { FrontalCode } from "./frontal-code.js"; import type { FrontalCodeEvent, InputEntry, JsonValue, ThreadInput, ThreadOptions, ThreadRunOptions, TurnResult, } from "./protocol.js"; import { collectTurn, streamEvents } from "./spawn.js"; /** A single conversation with the Frontal Code agent. */ export class Thread { private readonly frontal_code: FrontalCode; private readonly options: ThreadOptions; private sessionId?: string; constructor(frontal_code: FrontalCode, options: ThreadOptions, sessionId?: string) { this.frontal_code = frontal_code; this.options = options; this.sessionId = sessionId; } /** The persisted session id, once known. */ get id(): string | undefined { return this.sessionId; } /** * Run a turn and buffer the result. Call repeatedly on the same instance to * continue the conversation (the CLI is resumed via its session id). */ async run(input: ThreadInput, runOptions: ThreadRunOptions = {}): Promise { const args = this.buildArgs(input, runOptions); const env = this.frontal_code.buildEnv(); const turn = await collectTurn( streamEvents(this.frontal_code.command, args, env, this.options.workingDirectory), ); if (turn.sessionId) { this.sessionId = turn.sessionId; } return turn.result; } /** * Run a turn and stream structured events as they arrive. The returned * `events` is an async generator; `turn.completed` carries the final result. */ async runStreamed( input: ThreadInput, runOptions: ThreadRunOptions = {}, ): Promise<{ events: AsyncGenerator }> { const args = this.buildArgs(input, runOptions); const env = this.frontal_code.buildEnv(); const self = this; const generator = streamEvents( this.frontal_code.command, args, env, this.options.workingDirectory, ); async function* wrap(): AsyncGenerator { for await (const event of generator) { if (event.type === "turn.completed" && event.sessionId) { self.sessionId = event.sessionId; } yield event; } } return { events: wrap() }; } private toPrompt(input: ThreadInput): string { if (typeof input === "string") { return input; } // `local_image` entries are kept in the protocol for forward compatibility, // but the `frontal-code` CLI does not yet accept `--image`, so only text // entries are concatenated into the prompt. const texts: string[] = []; for (const entry of input as InputEntry[]) { if (entry.type === "text") { texts.push(entry.text); } } return texts.join("\n"); } private buildArgs(input: ThreadInput, runOptions: ThreadRunOptions): string[] { const prompt = this.toPrompt(input); const args = ["prompt", "-p", prompt]; if (this.options.provider) args.push("--provider", this.options.provider); if (this.options.model) args.push("--model", this.options.model); if (this.options.permissionMode) { args.push("--permission-mode", this.options.permissionMode); } if (this.options.dangerouslySkipPermissions) { args.push("--dangerously-skip-permissions"); } for (const tool of this.options.allowedTools ?? []) { args.push("--allowed-tools", tool); } args.push("--output-format", "json", "--stream"); if (this.sessionId) { args.push("--resume", this.sessionId); } const config = this.mergeConfig(runOptions); args.push(...configToArgs(config)); return args; } private mergeConfig(runOptions: ThreadRunOptions): Record { const config: Record = { ...(this.frontal_code.options.config ?? {}), }; if (this.frontal_code.options.baseUrl) { config.frontal_base_url = this.frontal_code.options.baseUrl; } if (this.options.skipGitRepoCheck) { config.skip_git_repo_check = true; } if (this.options.config) { Object.assign(config, this.options.config); } if (runOptions.config) { Object.assign(config, runOptions.config); } if (runOptions.outputSchema !== undefined) { config.output_schema = JSON.stringify(runOptions.outputSchema); } return config; } }