/** * The pi runtime: one pi `Agent` living INSIDE the daemon process. * * There is no child process, no port and no RPC. pi's tools run against this * sandbox's own filesystem and shell, the model goes through the Kortix LLM * gateway, and every lifecycle event is reshaped into the OpenCode wire the * product already renders (see wire.ts). One pi session IS one Kortix session: * the root id is a deterministic function of the session id, so a restart * resolves the same root and restores the same transcript from disk. * * Heavy dependencies (`@earendil-works/pi-*`) load on `start()`, never at * import: the resolver imports this module for every boot, including OpenCode's. */ import { createHash } from 'node:crypto' import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import type { Agent, AgentEvent, AgentMessage, AgentTool, ExecutionEnv, Skill } from '@earendil-works/pi-agent-core' import type { ImageContent, ModelThinkingLevel, TextContent, UserMessage } from '@earendil-works/pi-ai' import type { HarnessState } from '../lifecycle-contract' import type { ProjectEnvStore } from '../../project-env' import { kortixEventBus } from '../../kortix-event-bus' import { logger } from '../../logger' import { SECRET_CAPABILITIES_INSTRUCTION_PATH } from '../../secret-capabilities' import type { PiConfig } from './config' import { resolvePiSkillDirectories } from './config' import { PermissionBroker, QuestionBroker, compilePermissionPolicy, type PermissionPolicy, type QuestionRequestWire } from './interactions' import type { CatalogModel, PiModels, SelectedModel } from './model' import { nativeModelId } from './model' import { WireTranscript, type WireFrame, type WireMessage } from './transcript' import { PiWireAdapter, assistantMessageError, type WireEmission } from './wire' import { WIRE_MESSAGE_ID, WireIdClock, mintRootId } from './wire-id' export const PI_HARNESS_VERSION = 'pi-agent-core@0.85.1' /** OpenCode `AgentConfig`, as apps/api compiles it (compile-agent-config.ts). */ export interface CompiledAgent { description?: string mode?: 'primary' | 'subagent' | 'all' model?: string variant?: string temperature?: number top_p?: number prompt?: string disable?: boolean hidden?: boolean options?: Record color?: string steps?: number permission?: unknown } export interface CompiledAgentConfig { model?: string agent?: Record } export function parseCompiledAgentConfig(raw: string | undefined): CompiledAgentConfig | null { if (!raw?.trim()) return null try { const parsed = JSON.parse(raw) as unknown if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null return parsed as CompiledAgentConfig } catch { logger.warn('[pi] KORTIX_COMPILED_AGENT_CONFIG present but not valid JSON; ignoring') return null } } export const DEFAULT_SYSTEM_PROMPT = [ 'You are a coding agent working inside a Kortix sandbox. The project repository is checked out at the working directory.', 'Use the tools to read, search and change files and to run commands. Prefer small, verifiable steps. Report what you did and what remains.', ].join('\n') export interface PromptInput { messageID?: string text: string files: Array<{ mime: string; url: string; filename?: string }> agent?: string model?: { providerID: string; modelID: string } variant?: string system?: string } export class PromptRejected extends Error {} /** Validate the OpenCode `prompt_async` body before the runtime acknowledges it. */ export function parsePromptBody(raw: unknown): PromptInput { if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new PromptRejected('prompt body must be an object') const body = raw as Record if (body.messageID !== undefined) { if (typeof body.messageID !== 'string' || !WIRE_MESSAGE_ID.test(body.messageID)) { throw new PromptRejected('messageID must use the OpenCode wire format') } } if (body.model !== undefined) { const model = body.model as { providerID?: unknown; modelID?: unknown } | null if (!model || typeof model !== 'object' || typeof model.providerID !== 'string' || typeof model.modelID !== 'string') { throw new PromptRejected('model must contain providerID and modelID strings') } } if (body.agent !== undefined && (typeof body.agent !== 'string' || !body.agent)) throw new PromptRejected('agent must be a non-empty string') if (body.variant !== undefined && typeof body.variant !== 'string') throw new PromptRejected('variant must be a string') if (body.system !== undefined && typeof body.system !== 'string') throw new PromptRejected('system must be a string') if (!Array.isArray(body.parts) || body.parts.length === 0) throw new PromptRejected('parts must be a non-empty array') const text: string[] = [] const files: PromptInput['files'] = [] for (const rawPart of body.parts) { if (!rawPart || typeof rawPart !== 'object' || Array.isArray(rawPart)) throw new PromptRejected('prompt parts must be objects') const part = rawPart as Record if (part.type === 'text') { if (typeof part.text !== 'string') throw new PromptRejected('text parts require a string text field') text.push(part.text) continue } if (part.type === 'file') { if (typeof part.url !== 'string' || !part.url) throw new PromptRejected('file parts require a url') files.push({ mime: typeof part.mime === 'string' && part.mime ? part.mime : 'application/octet-stream', url: part.url, ...(typeof part.filename === 'string' ? { filename: part.filename } : {}), }) if (files.length > 16) throw new PromptRejected('at most 16 attachments are supported per prompt') continue } throw new PromptRejected(`prompt part type "${typeof part.type === 'string' ? part.type : 'unknown'}" is not supported`) } if (text.join('').trim().length === 0 && files.length === 0) throw new PromptRejected('prompt has no content') return { ...(typeof body.messageID === 'string' ? { messageID: body.messageID } : {}), text: text.join(''), files, ...(typeof body.agent === 'string' ? { agent: body.agent } : {}), ...(body.model ? { model: body.model as PromptInput['model'] } : {}), ...(typeof body.variant === 'string' ? { variant: body.variant } : {}), ...(typeof body.system === 'string' ? { system: body.system } : {}), } } export type TurnOutcome = 'completed' | 'error' | 'aborted' export interface TurnEnd { messageId: string status: 'idle' | 'error' error?: { name: string; message?: string } } export interface PiRuntimeHooks { onTurnBegin?: (turn: { rootId: string; messageId: string }) => void onTurnEnd?: (turn: TurnEnd & { rootId: string }) => void onQuestionAsked?: (request: QuestionRequestWire, answer: (answers: string[][]) => void) => void } export interface PiRuntimeOptions { cfg: PiConfig sessionId: string projectEnv?: ProjectEnvStore hooks?: PiRuntimeHooks env?: NodeJS.ProcessEnv now?: () => number } interface Turn { messageId: string input: PromptInput resolve: (outcome: TurnOutcome) => void outcome: Promise } interface Dump { version: 1 rootId: string title: string createdAt: number agentMessages: AgentMessage[] transcript: WireMessage[] turns: Array<{ messageId: string; status: 'idle' | 'error' }> } function decodeDataUrl(url: string): { mime: string; data: string } | null { const match = /^data:([^;,]+);base64,(.*)$/s.exec(url) return match ? { mime: match[1]!, data: match[2]! } : null } export class PiRuntime { readonly rootId: string readonly transcript = new WireTranscript() readonly permissions: PermissionBroker readonly questions: QuestionBroker readonly createdAt: number updatedAt: number title: string private readonly cfg: PiConfig private readonly env: NodeJS.ProcessEnv private readonly now: () => number private readonly hooks: PiRuntimeHooks private readonly clock = new WireIdClock() private state: HarnessState = 'down' private startError: string | null = null private agent: Agent | null = null private models: PiModels | null = null private selected: SelectedModel | null = null private executionEnv: ExecutionEnv | null = null private tools: AgentTool[] = [] private skills: Skill[] = [] private compiled: CompiledAgentConfig | null = null private agentName = 'build' private policy: PermissionPolicy = {} private adapter: PiWireAdapter | null = null private queue: Promise = Promise.resolve() private active: Turn | null = null private runningTools = 0 private abortAfterTool: { promptId: string; messageId: string } | null = null private status: 'idle' | 'busy' = 'idle' private readonly completedTurns = new Map() private workspaceReady = true constructor(opts: PiRuntimeOptions) { this.cfg = opts.cfg this.env = opts.env ?? process.env this.now = opts.now ?? (() => Date.now()) this.hooks = opts.hooks ?? {} this.rootId = mintRootId(opts.sessionId) this.createdAt = this.now() this.updatedAt = this.createdAt this.title = 'New session' this.permissions = new PermissionBroker(this.rootId, (frame) => this.publish(frame)) this.questions = new QuestionBroker(this.rootId, (frame) => this.publish(frame), (request) => { this.hooks.onQuestionAsked?.(request, (answers) => void this.questions.reply(request.id, answers)) }) } // ── lifecycle ──────────────────────────────────────────────────────────── getState(): HarnessState { return this.state } get lastStartError(): string | null { return this.startError } get workspace(): string { return this.cfg.projectTarget || this.cfg.workspace || '/workspace' } get sessionId(): string { return (this.env.KORTIX_SESSION_ID ?? '').trim() || 'session-local' } markWorkspaceReady(): void { this.workspaceReady = true } async start(): Promise { if (this.state === 'ok') return this.state = 'starting' this.startError = null const startedAt = this.now() try { const [{ createPiModels }, { createWorkspaceTools, createQuestionTool }, core, node] = await Promise.all([ import('./model'), import('./tools'), import('@earendil-works/pi-agent-core'), import('@earendil-works/pi-agent-core/node'), ]) this.compiled = parseCompiledAgentConfig(this.env.KORTIX_COMPILED_AGENT_CONFIG) this.agentName = this.resolveAgentName() this.models = await createPiModels({ mode: this.cfg.piModelMode, fauxScript: this.cfg.piFauxScript, env: this.env, defaultModelRef: this.env.KORTIX_OPENCODE_MODEL ?? this.compiledAgent()?.model ?? this.compiled?.model ?? null, }) this.selected = this.models.select(nativeModelId(this.env.KORTIX_OPENCODE_MODEL) ?? nativeModelId(this.compiledAgent()?.model ?? this.compiled?.model)) this.executionEnv = new node.NodeExecutionEnv({ cwd: this.workspace, shellEnv: this.env }) this.adapter = new PiWireAdapter({ sessionID: this.rootId, mintMessageId: () => this.clock.mint(this.now()), parentMessageId: () => this.active?.messageId ?? null, model: () => ({ providerID: this.selected!.providerID, modelID: this.selected!.modelID }), agent: this.agentName, workspace: this.workspace, now: this.now, publish: (frame) => this.publish(frame), }) this.tools = [ ...createWorkspaceTools(this.executionEnv), createQuestionTool(this.questions, (toolCallId) => this.adapter?.toolRef(toolCallId)), ] this.skills = await this.loadSkills(core.loadSkills) this.policy = compilePermissionPolicy(this.compiledAgent()?.permission) this.permissions.setPolicy(this.policy) const restored = this.restore() this.agent = new core.Agent({ streamFn: (model, context, options) => this.models!.models.streamSimple(model, context, options), toolExecution: 'sequential', initialState: { systemPrompt: this.systemPrompt(core.formatSkillsForSystemPrompt), model: this.selected.model, thinkingLevel: this.thinkingLevel(this.compiledAgent()?.variant), tools: this.tools, messages: restored?.agentMessages ?? [], }, }) this.agent.beforeToolCall = async (context) => { const tool = context.toolCall.name const rule = this.permissions.rule(tool, context.args) if (rule === 'deny') return { block: true, reason: `The project policy denies this ${tool} call.` } if (rule !== 'ask') return undefined const reply = await this.permissions.ask({ tool, args: context.args, ref: this.adapter?.toolRef(context.toolCall.id, { name: tool, args: context.args }), }) return reply === 'reject' ? { block: true, reason: 'The user rejected this tool call.' } : undefined } this.agent.subscribe((event) => this.onAgentEvent(event)) this.state = 'ok' logger.info('[pi] runtime ready', { rootId: this.rootId, model: `${this.selected.providerID}/${this.selected.modelID}`, agent: this.agentName, tools: this.tools.map((t) => t.name), skills: this.skills.length, restoredMessages: restored?.agentMessages.length ?? 0, ms: this.now() - startedAt, }) } catch (err) { this.state = 'down' this.startError = err instanceof Error ? err.message : String(err) logger.error('[pi] runtime start failed', { err: this.startError }) throw err } } async stop(): Promise { if (this.state === 'down') return await this.abort() await this.queue.catch(() => {}) this.persist() this.state = 'down' } async restart(): Promise { await this.stop() await this.start() } /** * Re-read the session environment live (`POST /kortix/env`): model, compiled * agent config, gateway target. pi has no process to respawn — the next turn * runs on the new settings, a running turn finishes on the old ones. */ async reconfigure(): Promise<{ changed: boolean }> { if (!this.agent || !this.models) return { changed: false } const { createPiModels } = await import('./model') const before = `${this.selected?.modelID}|${this.agentName}|${this.env.KORTIX_COMPILED_AGENT_CONFIG_ETAG ?? ''}` this.compiled = parseCompiledAgentConfig(this.env.KORTIX_COMPILED_AGENT_CONFIG) this.agentName = this.resolveAgentName() this.models = await createPiModels({ mode: this.cfg.piModelMode, fauxScript: this.cfg.piFauxScript, env: this.env, defaultModelRef: this.env.KORTIX_OPENCODE_MODEL ?? this.compiledAgent()?.model ?? this.compiled?.model ?? null, }) this.selected = this.models.select(nativeModelId(this.env.KORTIX_OPENCODE_MODEL) ?? nativeModelId(this.compiledAgent()?.model ?? this.compiled?.model)) this.policy = compilePermissionPolicy(this.compiledAgent()?.permission) this.permissions.setPolicy(this.policy) const core = await import('@earendil-works/pi-agent-core') this.skills = await this.loadSkills(core.loadSkills) this.agent.state.systemPrompt = this.systemPrompt(core.formatSkillsForSystemPrompt) this.agent.state.model = this.selected.model this.agent.state.thinkingLevel = this.thinkingLevel(this.compiledAgent()?.variant) const after = `${this.selected.modelID}|${this.agentName}|${this.env.KORTIX_COMPILED_AGENT_CONFIG_ETAG ?? ''}` return { changed: before !== after } } /** Reload skills from disk (after a repo refresh). */ async reloadSkills(): Promise { if (!this.agent) return 0 const core = await import('@earendil-works/pi-agent-core') this.skills = await this.loadSkills(core.loadSkills) this.agent.state.systemPrompt = this.systemPrompt(core.formatSkillsForSystemPrompt) return this.skills.length } // ── turns ──────────────────────────────────────────────────────────────── /** True while a turn runs. */ busy(): boolean { return this.status === 'busy' } activeTurnMessageId(): string | null { return this.active?.messageId ?? null } /** * Admit one prompt: publish its user message NOW (the transcript and every * subscriber see it before the model is called), then run it on the serial * queue. `done` settles with the turn's outcome. */ admit(input: PromptInput): { messageId: string; done: Promise } { if (!this.agent || this.state !== 'ok') throw new PromptRejected('pi runtime is not ready') if (!this.workspaceReady) throw new PromptRejected('workspace is not ready') const messageId = input.messageID ?? this.clock.mint(this.now()) if (this.transcript.messageById(messageId) || this.completedTurns.has(messageId)) { throw new PromptRejected(`message ${messageId} was already admitted`) } this.clock.observe(messageId) if (input.model) { const modelId = input.model.providerID === this.selected!.providerID ? input.model.modelID : nativeModelId(`${input.model.providerID}/${input.model.modelID}`) if (modelId && modelId !== this.selected!.modelID) this.selected = this.models!.select(modelId) } this.publishUserMessage(messageId, input) let resolve!: (outcome: TurnOutcome) => void const outcome = new Promise((r) => (resolve = r)) const turn: Turn = { messageId, input, resolve, outcome } this.queue = this.queue.then(() => this.runTurn(turn)).catch(() => {}) return { messageId, done: outcome } } /** Stop the run in flight. Idempotent: aborting an idle agent is a no-op. */ async abort(): Promise { if (!this.active) return false this.permissions.rejectAll() this.questions.rejectAll() this.agent?.abort() await this.active.outcome return true } /** * Quick Queue: end the named turn once no tool is running. A tool in flight * is never killed; the model's own streaming may be cut. */ armAbortAfterTool(input: { promptId: string; opencodeSessionId: string; messageId: string }): void { if (input.opencodeSessionId !== this.rootId) return this.abortAfterTool = { promptId: input.promptId, messageId: input.messageId } this.checkAbortAfterTool() } /** Without a prompt id, disarm whatever is pending. */ disarmAbortAfterTool(promptId?: string): void { if (promptId && this.abortAfterTool?.promptId !== promptId) return this.abortAfterTool = null } private checkAbortAfterTool(): void { const armed = this.abortAfterTool if (!armed) return // Idle, or another turn is running: a late arm must not stop its successor. if (this.active?.messageId !== armed.messageId) { this.abortAfterTool = null return } if (this.runningTools > 0) return this.abortAfterTool = null void this.abort() } private async runTurn(turn: Turn): Promise { const agent = this.agent! this.active = turn this.status = 'busy' this.hooks.onTurnBegin?.({ rootId: this.rootId, messageId: turn.messageId }) const originalPrompt = agent.state.systemPrompt let outcome: TurnOutcome = 'completed' let error: TurnEnd['error'] | undefined try { agent.state.model = this.selected!.model agent.state.thinkingLevel = this.thinkingLevel(turn.input.variant ?? this.compiledAgent()?.variant) if (turn.input.system) agent.state.systemPrompt = `${originalPrompt}\n\n${turn.input.system}` await agent.prompt(this.userMessage(turn.input)) const last = [...agent.state.messages].reverse().find((m) => m.role === 'assistant') as | { stopReason?: string; errorMessage?: string } | undefined if (last?.stopReason === 'aborted') outcome = 'aborted' else if (last && (last.stopReason === 'error' || last.stopReason === 'length')) { outcome = 'error' const wire = assistantMessageError({ stopReason: last.stopReason as never, errorMessage: last.errorMessage }) error = wire ? { name: wire.name, message: (wire.data as { message?: string }).message } : undefined } } catch (err) { outcome = 'error' const message = err instanceof Error ? err.message : String(err) error = { name: 'UnknownError', message } logger.error('[pi] turn failed', { messageId: turn.messageId, err: message }) this.publish({ type: 'session.error', properties: { sessionID: this.rootId, error: { name: 'UnknownError', data: { message } } } }) this.publish({ type: 'session.status', properties: { sessionID: this.rootId, status: { type: 'idle' } } }) this.publish({ type: 'session.idle', properties: { sessionID: this.rootId } }) } finally { agent.state.systemPrompt = originalPrompt this.permissions.rejectAll() this.questions.rejectAll() this.active = null this.runningTools = 0 this.abortAfterTool = null this.status = 'idle' this.completedTurns.set(turn.messageId, outcome === 'error' ? 'error' : 'idle') this.persist() turn.resolve(outcome) this.hooks.onTurnEnd?.({ rootId: this.rootId, messageId: turn.messageId, status: outcome === 'error' ? 'error' : 'idle', ...(error ? { error } : {}) }) } } private onAgentEvent(event: AgentEvent): void { if (event.type === 'tool_execution_start') this.runningTools += 1 if (event.type === 'tool_execution_end') this.runningTools = Math.max(0, this.runningTools - 1) this.translateAndPublish(event) // After the frames: the finished tool part is on the transcript before the turn is cut. if (event.type === 'tool_execution_end') this.checkAbortAfterTool() } private translateAndPublish(event: AgentEvent): void { if (!this.adapter) return let frames: WireEmission[] try { frames = this.adapter.translate(event) } catch (err) { logger.warn('[pi] wire translation failed', { type: event.type, err: (err as Error).message }) return } for (const frame of frames) this.publish(frame, frame.transcriptOnly ? { transcriptOnly: true } : undefined) } /** Sequence one wire frame onto the bus AND fold it into the transcript. */ publish(frame: WireFrame, opts: { transcriptOnly?: boolean; busOnly?: boolean } = {}): void { this.updatedAt = this.now() if (frame.type === 'session.status') { const status = (frame.properties.status as { type?: string } | undefined)?.type if (status === 'busy' || status === 'idle') this.status = status } if (!opts.busOnly) this.transcript.apply(frame) if (opts.transcriptOnly) return const p = frame.properties const session = (p.sessionID as string | undefined) ?? (p.info as { sessionID?: string } | undefined)?.sessionID ?? (p.part as { sessionID?: string } | undefined)?.sessionID kortixEventBus().publish(frame.type, p, session) } private publishUserMessage(messageId: string, input: PromptInput): void { const created = this.now() if (this.title === 'New session') { const line = input.text.trim().split('\n')[0] ?? '' if (line) this.title = line.length > 80 ? `${line.slice(0, 77)}…` : line } this.publish({ type: 'message.updated', properties: { sessionID: this.rootId, info: { id: messageId, role: 'user', sessionID: this.rootId, time: { created }, agent: this.agentName, model: { providerID: this.selected!.providerID, modelID: this.selected!.modelID, ...(input.variant ? { variant: input.variant } : {}) }, }, }, }) let index = 0 if (input.text) { this.publish({ type: 'message.part.updated', properties: { sessionID: this.rootId, time: created, part: { id: `${messageId}-p${index++}`, messageID: messageId, sessionID: this.rootId, type: 'text', text: input.text }, }, }) } for (const file of input.files) { this.publish({ type: 'message.part.updated', properties: { sessionID: this.rootId, time: created, part: { id: `${messageId}-p${index++}`, messageID: messageId, sessionID: this.rootId, type: 'file', mime: file.mime, url: file.url, ...(file.filename ? { filename: file.filename } : {}), }, }, }) } } private userMessage(input: PromptInput): UserMessage { const images: ImageContent[] = [] if (this.selected?.images) { for (const file of input.files) { if (!file.mime.startsWith('image/')) continue const decoded = decodeDataUrl(file.url) if (decoded) images.push({ type: 'image', data: decoded.data, mimeType: decoded.mime }) } } const text: TextContent = { type: 'text', text: input.text || '(attachment)' } return { role: 'user', content: images.length ? [text, ...images] : input.text, timestamp: this.now() } } // ── configuration ──────────────────────────────────────────────────────── private resolveAgentName(): string { const requested = (this.env.KORTIX_AGENT_NAME ?? '').trim() const agents = Object.keys(this.compiled?.agent ?? {}) if (requested && requested !== 'default' && (agents.length === 0 || agents.includes(requested))) return requested return agents[0] ?? 'build' } private compiledAgent(): CompiledAgent | undefined { return this.compiled?.agent?.[this.agentName] } private thinkingLevel(variant: string | undefined): ModelThinkingLevel { if (!this.selected || !variant) return 'off' return this.selected.variants.includes(variant) ? (variant as ModelThinkingLevel) : 'off' } private async loadSkills(load: typeof import('@earendil-works/pi-agent-core').loadSkills): Promise { if (!this.executionEnv) return [] const dirs = resolvePiSkillDirectories(this.cfg).filter((dir) => existsSync(dir)) if (dirs.length === 0) return [] try { const { BACKGROUND_CONTEXT } = await import('@earendil-works/pi-agent-core/harness/context') const { skills, diagnostics } = await load(this.executionEnv, dirs, BACKGROUND_CONTEXT) for (const diagnostic of diagnostics) logger.warn('[pi] skill diagnostic', diagnostic) // Two directories can carry the same skill (a project's OpenCode copy and // the managed overlay): the first directory wins, like OpenCode's search order. const seen = new Set() return skills.filter((skill) => (seen.has(skill.name) ? false : (seen.add(skill.name), true))) } catch (err) { logger.warn('[pi] skill load failed', { err: (err as Error).message }) return [] } } private systemPrompt(formatSkills: (skills: Skill[]) => string): string { const parts = [this.compiledAgent()?.prompt?.trim() || DEFAULT_SYSTEM_PROMPT] parts.push(`Working directory: ${this.workspace}`) if (this.skills.length > 0) parts.push(formatSkills(this.skills)) const capabilities = this.readInstruction(SECRET_CAPABILITIES_INSTRUCTION_PATH) if (capabilities) parts.push(capabilities) parts.push( [ '## Runtime capabilities', `Registered tools: ${this.tools.map((t) => t.name).join(', ')}.`, 'Call tools normally; the runtime asks the user for permission when the project policy requires it.', 'Use question to collect answers through the interactive question UI.', ].join('\n'), ) return parts.join('\n\n') } private readInstruction(path: string): string | null { try { const text = readFileSync(path, 'utf8').trim() return text || null } catch { return null } } // ── projections ────────────────────────────────────────────────────────── selectedModel(): SelectedModel | null { return this.selected } agentNameValue(): string { return this.agentName } skillList(): Skill[] { return this.skills } toolList(): Array<{ id: string; description: string; parameters: unknown }> { return this.tools.map((tool) => ({ id: tool.name, description: tool.description, parameters: tool.parameters })) } sessionStatus(): { type: 'idle' | 'busy' } { return { type: this.status } } /** The OpenCode `Session` object for this root. */ sessionObject(): Record { return { id: this.rootId, slug: this.rootId, projectID: (this.env.KORTIX_PROJECT_ID ?? '').trim() || this.sessionId, directory: this.workspace, title: this.title, version: PI_HARNESS_VERSION, time: { created: this.createdAt, updated: this.updatedAt }, } } /** The OpenCode `Agent` object for the selected agent. */ agentObject(): Record { const agent = this.compiledAgent() ?? {} return { name: this.agentName, ...(agent.description !== undefined ? { description: agent.description } : {}), mode: agent.mode ?? 'primary', native: false, hidden: agent.hidden === true || agent.disable === true, ...(agent.top_p !== undefined ? { topP: agent.top_p } : {}), ...(agent.temperature !== undefined ? { temperature: agent.temperature } : {}), ...(agent.color !== undefined ? { color: agent.color } : {}), permission: this.policy, ...(this.selected ? { model: { providerID: this.selected.providerID, modelID: this.selected.modelID } } : {}), ...(agent.variant !== undefined ? { variant: agent.variant } : {}), ...(agent.prompt !== undefined ? { prompt: agent.prompt } : {}), options: { ...(agent.options ?? {}) }, ...(agent.steps !== undefined ? { steps: agent.steps } : {}), } } /** The OpenCode `Config` document the picker and composer read. */ configObject(): Record { const selected = this.selected const provider = selected && (selected.variants.length > 0 || selected.images) ? { provider: { [selected.providerID]: { models: { [selected.modelID]: { ...(selected.variants.length ? { variants: Object.fromEntries(selected.variants.map((v) => [v, {}])) } : {}), attachment: selected.images, }, }, }, }, } : {} return { ...provider, default_agent: this.agentName, ...(selected ? { model: `${selected.providerID}/${selected.modelID}` } : {}), agent: this.compiled?.agent ?? {}, permission: this.compiledAgent()?.permission ?? {}, autoupdate: false, } } /** The OpenCode `/provider` document: one gateway provider, the catalog as its models. */ providerList(): Record { const selected = this.selected const catalog = this.models?.catalog ?? {} const providerID = selected?.providerID ?? 'kortix' const models: Record = {} const entries: Array<[string, CatalogModel]> = Object.keys(catalog).length > 0 ? Object.entries(catalog) : selected ? [[selected.modelID, { name: selected.modelID }]] : [] for (const [id, entry] of entries) { models[id] = { id, providerID, name: entry.name ?? id, api: { id: 'openai-completions', url: '', npm: '@earendil-works/pi-ai' }, capabilities: { temperature: true, reasoning: entry.reasoning === true, attachment: entry.attachment === true, toolcall: true, input: { text: true, audio: false, image: entry.attachment === true, video: false, pdf: false }, output: { text: true, audio: false, image: false, video: false, pdf: false }, interleaved: false, }, cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, limit: { context: entry.limit?.context ?? 128_000, output: entry.limit?.output ?? 32_768 }, status: 'active', options: {}, headers: {}, release_date: '', variants: Object.fromEntries(Object.keys(entry.variants ?? {}).map((v) => [v, {}])), } } const provider = { id: providerID, name: providerID === 'faux' ? 'Faux' : 'Kortix', source: 'config', env: [], options: {}, models } return { all: [provider], default: selected ? { [providerID]: selected.modelID } : {}, connected: [providerID], } } /** The `/kortix/opencode/state` document. */ stateDoc(): Record { const bus = kortixEventBus() const selected = this.selected const agents = Object.entries(this.compiled?.agent ?? { [this.agentName]: this.compiledAgent() ?? {} }).map(([name, agent]) => ({ name, description: agent?.description ?? null, mode: agent?.mode ?? null, native: false, hidden: agent?.hidden ?? null, color: agent?.color ?? null, variant: agent?.variant ?? null, source: 'config', model: name === this.agentName && selected ? { providerID: selected.providerID, modelID: selected.modelID } : agent?.model && nativeModelId(agent.model) ? { providerID: 'kortix', modelID: nativeModelId(agent.model)! } : null, })) return { epoch: bus.epoch, seq: bus.headSeq, built_at: new Date(this.now()).toISOString(), identity: { opencode_session_id: this.rootId, opencode_version: PI_HARNESS_VERSION, daemon_build: null, agent_config_etag: this.env.KORTIX_COMPILED_AGENT_CONFIG_ETAG || null, head_seq: null, harness: 'pi', }, agents: { known: true, value: agents }, commands: { known: true, value: [] }, config: { known: true, value: { model: selected ? `${selected.providerID}/${selected.modelID}` : null, small_model: null, default_agent: this.agentName, permission: this.compiledAgent()?.permission ?? null, instructions: null, enabled_providers: selected ? [selected.providerID] : null, }, }, sessions: { known: true, value: [ { id: this.rootId, title: this.title, parent_id: null, directory: this.workspace, time: { created: this.createdAt, updated: this.updatedAt, compacting: null }, revert: null, }, ], }, statuses: { known: true, value: { [this.rootId]: this.sessionStatus() } }, permissions: { known: true, value: this.permissions.list() }, questions: { known: true, value: this.questions.list() }, } } stateEtag(doc: Record): string { const { built_at: _built, ...rest } = doc return `"sha256-${createHash('sha256').update(JSON.stringify(rest)).digest('hex').slice(0, 32)}"` } /** * The turn probe behind `/kortix/health?turn=1` and `/kortix/opencode/turn/:id`. * The reaper renews a box's deadline on `inFlight`, records `end` when a * turn is over, and redelivers a prompt reported `abandoned`. */ turnProbe(messageId: string | null): { inFlight: boolean; end: 'completed' | 'failed' | 'abandoned' | null; orphanedPrompt: boolean } { if (messageId === null) { return { inFlight: this.busy(), end: this.busy() ? null : this.latestEnd(), orphanedPrompt: false } } if (this.active?.messageId === messageId) return { inFlight: true, end: null, orphanedPrompt: false } const completed = this.completedTurns.get(messageId) if (completed) return { inFlight: false, end: completed === 'error' ? 'failed' : 'completed', orphanedPrompt: false } // Queued behind the running turn: on record, not yet answered, still ours. if (this.transcript.messageById(messageId)) return { inFlight: true, end: null, orphanedPrompt: false } return { inFlight: false, end: 'abandoned', orphanedPrompt: true } } private latestEnd(): 'completed' | 'failed' | null { const last = [...this.completedTurns.values()].at(-1) return last ? (last === 'error' ? 'failed' : 'completed') : null } // ── durability ─────────────────────────────────────────────────────────── private dumpPath(): string { return join(this.cfg.piStateDir, `${this.sessionId}.json`) } private persist(): void { if (!this.agent) return try { mkdirSync(this.cfg.piStateDir, { recursive: true, mode: 0o700 }) const dump: Dump = { version: 1, rootId: this.rootId, title: this.title, createdAt: this.createdAt, agentMessages: this.agent.state.messages, transcript: this.transcript.all(), turns: [...this.completedTurns.entries()].map(([messageId, status]) => ({ messageId, status })), } const tmp = `${this.dumpPath()}.tmp` writeFileSync(tmp, JSON.stringify(dump), { mode: 0o600 }) renameSync(tmp, this.dumpPath()) } catch (err) { logger.warn('[pi] transcript persist failed', { err: (err as Error).message }) } } private restore(): Dump | null { try { if (!existsSync(this.dumpPath())) return null const dump = JSON.parse(readFileSync(this.dumpPath(), 'utf8')) as Dump if (dump.version !== 1 || dump.rootId !== this.rootId) return null this.transcript.load(dump.transcript) for (const message of dump.transcript) this.clock.observe(message.info.id as string) for (const turn of dump.turns) this.completedTurns.set(turn.messageId, turn.status) this.title = dump.title return dump } catch (err) { logger.warn('[pi] transcript restore failed; starting empty', { err: (err as Error).message }) return null } } }