programmatic subagents
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(drivers): replace stub pi/claude/codex drivers with real process integrations

+1150 -232
+14 -9
packages/cli/src/public/index.api.test.ts
··· 197 197 198 198 const payload = Schema.decodeUnknownSync(DiscoveryEnvelope)(stdout[0]); 199 199 expect(payload.discoveryVersion).toBe(1); 200 - expect(payload.drivers.pi?.models).toEqual([ 201 - "openai/gpt-5.3-codex", 202 - "anthropic/claude-sonnet-4-6", 203 - ]); 200 + expect((payload.drivers.pi?.models.length ?? 0) > 0).toBe(true); 201 + expect(Array.isArray(payload.drivers.claude?.models)).toBe(true); 202 + expect(Array.isArray(payload.drivers.codex?.models)).toBe(true); 204 203 expect(payload.programApi.spawnRequired).toEqual(["agent", "systemPrompt", "prompt"]); 205 - expect(payload.drivers.codex?.models).toEqual(["openai/gpt-5.3-codex"]); 206 204 expect(payload.executors.direct?.description).toBe("Local direct executor"); 207 205 expect(payload.executors.vm).toBeUndefined(); 208 206 }); ··· 306 304 try { 307 305 const runStdout: Array<string> = []; 308 306 const runCode = await runCli( 309 - ["run", programPath, "--sync", "--json", "--driver", "codex", "--executor", "direct"], 307 + ["run", programPath, "--sync", "--json", "--driver", "pi", "--executor", "direct"], 310 308 { 311 309 cwd: tempDirectory, 312 310 homeDirectory, 313 - pathExists: async () => false, 311 + pathExists: async (path) => path === join(tempDirectory, "mill.config.ts"), 312 + loadConfigModule: async () => ({ 313 + default: { 314 + defaultDriver: "claude", 315 + defaultExecutor: "direct", 316 + defaultModel: "google-gemini-cli/gemini-2.0-flash", 317 + }, 318 + }), 314 319 io: { 315 320 stdout: (line) => { 316 321 runStdout.push(line); ··· 323 328 expect(runCode).toBe(0); 324 329 325 330 const payload = Schema.decodeUnknownSync(RunSyncEnvelope)(runStdout[0]); 326 - expect(payload.run.driver).toBe("codex"); 331 + expect(payload.run.driver).toBe("pi"); 327 332 expect(payload.run.executor).toBe("direct"); 328 - expect(payload.result.spawns[0]?.driver).toBe("codex"); 333 + expect(payload.result.spawns[0]?.driver).toBe("pi"); 329 334 } finally { 330 335 await rm(tempDirectory, { recursive: true, force: true }); 331 336 }
+62 -4
packages/cli/src/public/index.api.ts
··· 1 - import { Args, Command as CliCommand, Options, ValidationError } from "@effect/cli"; 1 + import { Args, CliConfig, Command as CliCommand, Options, ValidationError } from "@effect/cli"; 2 2 import * as PlatformCommand from "@effect/platform/Command"; 3 3 import * as FileSystem from "@effect/platform/FileSystem"; 4 4 import * as BunContext from "@effect/platform-bun/BunContext"; ··· 64 64 const defaultConfig = defineConfig({ 65 65 defaultDriver: "pi", 66 66 defaultExecutor: "direct", 67 - defaultModel: "openai/gpt-5.3-codex", 67 + defaultModel: "openai-codex/gpt-5.3-codex", 68 68 drivers: { 69 69 pi: processDriver(createPiDriverRegistration()), 70 70 claude: processDriver(createClaudeDriverRegistration()), ··· 241 241 "export default defineConfig({", 242 242 ' defaultDriver: "pi",', 243 243 ' defaultExecutor: "direct",', 244 - ' defaultModel: "openai/gpt-5.3-codex",', 244 + ' defaultModel: "openai-codex/gpt-5.3-codex",', 245 245 " drivers: {", 246 246 " pi: processDriver(createPiDriverRegistration()),", 247 247 " claude: processDriver(createClaudeDriverRegistration()),", ··· 685 685 ); 686 686 }; 687 687 688 + const HELP_TEXT = `mill - orchestration runtime for AI agents 689 + 690 + Usage: mill <command> [options] 691 + 692 + Commands: 693 + run <program.ts> Run a mill program 694 + status <runId> Show run state 695 + wait <runId> --timeout <s> Wait for terminal state 696 + watch <runId> Stream run events 697 + inspect <ref> Inspect run or spawn detail 698 + cancel <runId> Cancel a running execution 699 + ls List runs 700 + init Create starter mill.config.ts 701 + discovery Emit discovery metadata 702 + 703 + Global options: --json, --driver <name>, --runs-dir <path> 704 + 705 + Examples: 706 + 707 + Sequential pipeline: 708 + const scan = await mill.spawn({ 709 + agent: "scout", 710 + systemPrompt: "You are a code risk analyst.", 711 + prompt: "Review src/auth and summarize top security risks.", 712 + }); 713 + const plan = await mill.spawn({ 714 + agent: "planner", 715 + systemPrompt: "You turn findings into an execution-ready plan.", 716 + prompt: \`Create remediation steps from:\\n\\n\${scan.text}\`, 717 + }); 718 + 719 + Parallel fan-out: 720 + const [security, perf] = await Promise.all([ 721 + mill.spawn({ agent: "security", systemPrompt: "...", prompt: "Review src/auth/" }), 722 + mill.spawn({ agent: "perf", systemPrompt: "...", prompt: "Profile src/api/" }), 723 + ]); 724 + 725 + Authoring: 726 + systemPrompt = WHO the agent is (personality, methodology, output format) 727 + prompt = WHAT to do now (specific files, concrete task) 728 + Prefer cheaper models for search, stronger models for synthesis. 729 + 730 + Run mill <command> --help for details.`; 731 + 732 + const isHelpRequest = (argv: ReadonlyArray<string>): boolean => { 733 + if (argv.length === 0) return true; 734 + 735 + return argv.length === 1 && (argv[0] === "--help" || argv[0] === "-h"); 736 + }; 737 + 688 738 export const runCli = async ( 689 739 argv: ReadonlyArray<string>, 690 740 options?: RunCliOptions, 691 741 ): Promise<number> => { 692 742 const resolvedOptions = options ?? {}; 693 743 const io = resolvedOptions.io ?? defaultIo; 744 + 745 + if (isHelpRequest(argv)) { 746 + io.stdout(HELP_TEXT); 747 + return 0; 748 + } 749 + 694 750 const command = createCli(resolvedOptions, io); 695 751 const run = CliCommand.run(command, { 696 752 name: "mill", ··· 710 766 ), 711 767 ); 712 768 713 - return runWithBunContext(codeEffect); 769 + const compactHelp = CliConfig.layer({ showBuiltIns: false, showTypes: false }); 770 + 771 + return runWithBunContext(Effect.provide(codeEffect, compactHelp)); 714 772 };
+10 -12
packages/cli/src/public/index.e2e.test.ts
··· 157 157 const payload = Schema.decodeUnknownSync(DiscoveryEnvelope)(output); 158 158 expect(payload.discoveryVersion).toBe(1); 159 159 expect(payload.programApi.spawnRequired).toEqual(["agent", "systemPrompt", "prompt"]); 160 - expect(payload.drivers.pi?.models).toEqual([ 161 - "openai/gpt-5.3-codex", 162 - "anthropic/claude-sonnet-4-6", 163 - ]); 164 - expect(payload.drivers.claude?.models).toEqual(["anthropic/claude-sonnet-4-6"]); 165 - expect(payload.drivers.codex?.models).toEqual(["openai/gpt-5.3-codex"]); 160 + expect((payload.drivers.pi?.models.length ?? 0) > 0).toBe(true); 161 + expect(Array.isArray(payload.drivers.claude?.models)).toBe(true); 162 + expect(Array.isArray(payload.drivers.codex?.models)).toBe(true); 166 163 expect(payload.executors.direct?.description).toBe("Local direct executor"); 167 164 expect(payload.executors.vm).toBeUndefined(); 168 165 expect(payload.authoring.instructions.length).toBeGreaterThan(0); ··· 174 171 Command.make("bun", "run", "packages/cli/src/bin/mill.ts", "--help"), 175 172 ); 176 173 177 - expect(output).toContain("USAGE"); 178 - expect(output).toContain("$ mill"); 179 - expect(output).toContain("COMMANDS"); 174 + expect(output).toContain("Usage: mill <command>"); 175 + expect(output).toContain("Commands:"); 176 + expect(output).toContain("run <program.ts>"); 180 177 expect(output).not.toContain("Effect-first"); 181 178 }); 182 179 ··· 204 201 ' agent: "scout",', 205 202 ' systemPrompt: "You are concise.",', 206 203 ' prompt: "Inspect repository layout.",', 204 + ' model: "google-gemini-cli/gemini-2.0-flash",', 207 205 "});", 208 206 "return output.text;", 209 207 ].join("\n"), ··· 221 219 "--sync", 222 220 "--json", 223 221 "--driver", 224 - "codex", 222 + "pi", 225 223 "--executor", 226 224 "direct", 227 225 "--runs-dir", ··· 230 228 ); 231 229 232 230 const runPayload = Schema.decodeUnknownSync(RunSyncEnvelope)(runOutput); 233 - expect(runPayload.run.driver).toBe("codex"); 231 + expect(runPayload.run.driver).toBe("pi"); 234 232 expect(runPayload.run.executor).toBe("direct"); 235 - expect(runPayload.result.spawns[0]?.driver).toBe("codex"); 233 + expect(runPayload.result.spawns[0]?.driver).toBe("pi"); 236 234 } finally { 237 235 await rm(tempDirectory, { recursive: true, force: true }); 238 236 }
+1
packages/driver-claude/src/claude.codec.ts
··· 1 + export * from "./internal/claude.codec";
+210
packages/driver-claude/src/internal/claude.codec.ts
··· 1 + import { Data, Effect } from "effect"; 2 + import type { DriverSpawnEvent, DriverSpawnOutput } from "@mill/core"; 3 + 4 + export class ClaudeCodecError extends Data.TaggedError("ClaudeCodecError")<{ 5 + message: string; 6 + }> {} 7 + 8 + type DecodeClaudeProcessInput = { 9 + readonly agent: string; 10 + readonly model: string; 11 + readonly spawnId: string; 12 + }; 13 + 14 + type JsonRecord = Readonly<Record<string, unknown>>; 15 + 16 + const isRecord = (value: unknown): value is JsonRecord => 17 + typeof value === "object" && value !== null && !Array.isArray(value); 18 + 19 + const readString = (record: JsonRecord, key: string): string | undefined => { 20 + const value = record[key]; 21 + return typeof value === "string" ? value : undefined; 22 + }; 23 + 24 + const decodeJsonLine = (line: string): Effect.Effect<JsonRecord, ClaudeCodecError> => 25 + Effect.flatMap( 26 + Effect.try({ 27 + try: () => JSON.parse(line) as unknown, 28 + catch: (error) => 29 + new ClaudeCodecError({ 30 + message: String(error), 31 + }), 32 + }), 33 + (parsed) => 34 + isRecord(parsed) 35 + ? Effect.succeed(parsed) 36 + : Effect.fail( 37 + new ClaudeCodecError({ 38 + message: "line must decode to a JSON object", 39 + }), 40 + ), 41 + ); 42 + 43 + const extractAssistantText = (message: JsonRecord): string | undefined => { 44 + const content = message.content; 45 + 46 + if (!Array.isArray(content)) { 47 + return undefined; 48 + } 49 + 50 + const textParts = content 51 + .map((entry) => { 52 + if (!isRecord(entry)) { 53 + return undefined; 54 + } 55 + 56 + if (readString(entry, "type") !== "text") { 57 + return undefined; 58 + } 59 + 60 + return readString(entry, "text"); 61 + }) 62 + .filter((text): text is string => text !== undefined && text.length > 0); 63 + 64 + if (textParts.length === 0) { 65 + return undefined; 66 + } 67 + 68 + return textParts.join("\n"); 69 + }; 70 + 71 + export const decodeClaudeProcessOutput = ( 72 + output: string, 73 + input: DecodeClaudeProcessInput, 74 + ): Effect.Effect<DriverSpawnOutput, ClaudeCodecError> => 75 + Effect.gen(function* () { 76 + const lines = output 77 + .split("\n") 78 + .map((line) => line.trim()) 79 + .filter((line) => line.length > 0); 80 + 81 + const decodedLines = yield* Effect.forEach(lines, decodeJsonLine); 82 + 83 + const events: Array<DriverSpawnEvent> = []; 84 + let sessionRef: string | undefined; 85 + let responseText: string | undefined; 86 + let stopReason: string | undefined; 87 + let exitCode = 0; 88 + let errorMessage: string | undefined; 89 + let terminalSeen = false; 90 + 91 + for (const decoded of decodedLines) { 92 + const eventType = readString(decoded, "type"); 93 + 94 + if (terminalSeen && eventType !== "result") { 95 + return yield* Effect.fail( 96 + new ClaudeCodecError({ 97 + message: `Non-terminal line ${eventType ?? "unknown"} emitted after terminal result.`, 98 + }), 99 + ); 100 + } 101 + 102 + if (eventType === "system") { 103 + const currentSessionRef = readString(decoded, "session_id"); 104 + 105 + if (currentSessionRef !== undefined) { 106 + sessionRef = currentSessionRef; 107 + events.push({ 108 + type: "milestone", 109 + message: "session:start", 110 + }); 111 + } 112 + 113 + continue; 114 + } 115 + 116 + if (eventType === "assistant") { 117 + const message = decoded.message; 118 + 119 + if (!isRecord(message)) { 120 + continue; 121 + } 122 + 123 + const content = message.content; 124 + 125 + if (Array.isArray(content)) { 126 + for (const entry of content) { 127 + if (!isRecord(entry)) { 128 + continue; 129 + } 130 + 131 + if (readString(entry, "type") !== "tool_use") { 132 + continue; 133 + } 134 + 135 + const toolName = readString(entry, "name"); 136 + 137 + if (toolName === undefined) { 138 + continue; 139 + } 140 + 141 + events.push({ 142 + type: "tool_call", 143 + toolName, 144 + }); 145 + } 146 + } 147 + 148 + const assistantText = extractAssistantText(message); 149 + 150 + if (assistantText !== undefined) { 151 + responseText = assistantText; 152 + } 153 + 154 + continue; 155 + } 156 + 157 + if (eventType === "result") { 158 + if (terminalSeen) { 159 + return yield* Effect.fail( 160 + new ClaudeCodecError({ 161 + message: "Duplicate terminal result lines are not allowed.", 162 + }), 163 + ); 164 + } 165 + 166 + terminalSeen = true; 167 + 168 + const resultText = readString(decoded, "result"); 169 + 170 + if (resultText !== undefined) { 171 + responseText = resultText; 172 + } 173 + 174 + const resultSessionRef = readString(decoded, "session_id"); 175 + 176 + if (resultSessionRef !== undefined) { 177 + sessionRef = resultSessionRef; 178 + } 179 + 180 + stopReason = readString(decoded, "stop_reason"); 181 + 182 + if (decoded.is_error === true) { 183 + exitCode = 1; 184 + errorMessage = resultText ?? "claude command failed"; 185 + } 186 + } 187 + } 188 + 189 + if (!terminalSeen) { 190 + return yield* Effect.fail( 191 + new ClaudeCodecError({ 192 + message: "Missing terminal result line from claude process output.", 193 + }), 194 + ); 195 + } 196 + 197 + return { 198 + events, 199 + result: { 200 + text: responseText ?? "", 201 + sessionRef: sessionRef ?? `session/${input.spawnId}`, 202 + agent: input.agent, 203 + model: input.model, 204 + driver: "claude", 205 + exitCode, 206 + stopReason, 207 + errorMessage, 208 + }, 209 + } satisfies DriverSpawnOutput; 210 + });
+85
packages/driver-claude/src/internal/process-driver.effect.ts
··· 1 + import * as Command from "@effect/platform/Command"; 2 + import { Data, Effect } from "effect"; 3 + import type { DriverProcessConfig, DriverRuntime, DriverSpawnInput } from "@mill/core"; 4 + import { decodeClaudeProcessOutput } from "./claude.codec"; 5 + 6 + export class ClaudeProcessDriverError extends Data.TaggedError("ClaudeProcessDriverError")<{ 7 + message: string; 8 + }> {} 9 + 10 + const toMessage = (error: unknown): string => { 11 + if (error instanceof Error) { 12 + return error.message; 13 + } 14 + 15 + return String(error); 16 + }; 17 + 18 + const normalizeClaudeModel = (model: string): string => { 19 + const parts = model.split("/"); 20 + return parts[parts.length - 1] ?? model; 21 + }; 22 + 23 + const commandForSpawn = ( 24 + config: DriverProcessConfig, 25 + input: DriverSpawnInput, 26 + ): Command.Command => { 27 + const command = Command.make( 28 + config.command, 29 + ...config.args, 30 + "--model", 31 + normalizeClaudeModel(input.model), 32 + "--system-prompt", 33 + input.systemPrompt, 34 + input.prompt, 35 + ).pipe(Command.stdin("ignore")); 36 + 37 + if (config.env === undefined || Object.keys(config.env).length === 0) { 38 + return command; 39 + } 40 + 41 + return Command.env(command, config.env); 42 + }; 43 + 44 + export const makeClaudeProcessDriver = (config: DriverProcessConfig): DriverRuntime => ({ 45 + name: "claude", 46 + spawn: (input) => 47 + Effect.gen(function* () { 48 + const command = commandForSpawn(config, input); 49 + const stdout = yield* Effect.mapError( 50 + Command.string(command), 51 + (error) => 52 + new ClaudeProcessDriverError({ 53 + message: toMessage(error), 54 + }), 55 + ); 56 + 57 + const decoded = yield* Effect.mapError( 58 + decodeClaudeProcessOutput(stdout, { 59 + agent: input.agent, 60 + model: input.model, 61 + spawnId: input.spawnId, 62 + }), 63 + (error) => 64 + new ClaudeProcessDriverError({ 65 + message: toMessage(error), 66 + }), 67 + ); 68 + 69 + const raw = stdout 70 + .split("\n") 71 + .map((line) => line.trim()) 72 + .filter((line) => line.length > 0); 73 + 74 + return { 75 + ...decoded, 76 + raw, 77 + }; 78 + }), 79 + resolveSession: ({ sessionRef }) => 80 + Effect.succeed({ 81 + driver: "claude", 82 + sessionRef, 83 + pointer: `claude://session/${encodeURIComponent(sessionRef)}`, 84 + }), 85 + });
+1
packages/driver-claude/src/process-driver.effect.ts
··· 1 + export * from "./internal/process-driver.effect";
+18 -4
packages/driver-claude/src/public/index.api.test.ts
··· 5 5 6 6 const runtime = Runtime.defaultRuntime; 7 7 8 + const CLAUDE_JSON_FIXTURE_SCRIPT = 9 + "console.log(JSON.stringify({type:'system',session_id:'claude-session'}));" + 10 + "console.log(JSON.stringify({type:'assistant',message:{content:[{type:'tool_use',name:'Bash'},{type:'text',text:'Working...'}]}}));" + 11 + "console.log(JSON.stringify({type:'result',subtype:'success',is_error:false,result:'done',stop_reason:'stop',session_id:'claude-session'}));"; 12 + 8 13 describe("createClaudeDriverRegistration", () => { 9 - it("exposes catalog-backed model discovery", async () => { 10 - const driver = createClaudeDriverRegistration(); 14 + it("supports explicit model catalogs", async () => { 15 + const driver = createClaudeDriverRegistration({ 16 + models: ["anthropic/claude-sonnet-4-6"], 17 + }); 11 18 const models = await Runtime.runPromise(runtime)(driver.codec.modelCatalog); 12 19 13 20 expect(models).toEqual(["anthropic/claude-sonnet-4-6"]); ··· 15 22 }); 16 23 17 24 it("spawns runtime outputs via generic driver contracts", async () => { 18 - const driver = createClaudeDriverRegistration(); 25 + const driver = createClaudeDriverRegistration({ 26 + process: { 27 + command: "bun", 28 + args: ["-e", CLAUDE_JSON_FIXTURE_SCRIPT], 29 + }, 30 + models: ["anthropic/claude-sonnet-4-6"], 31 + }); 19 32 20 33 if (driver.runtime === undefined) { 21 34 return; ··· 36 49 ); 37 50 38 51 expect(output.result.driver).toBe("claude"); 39 - expect(output.result.sessionRef.length).toBeGreaterThan(0); 52 + expect(output.result.sessionRef).toBe("claude-session"); 53 + expect(output.events.some((event) => event.type === "tool_call")).toBe(true); 40 54 }); 41 55 });
+12 -28
packages/driver-claude/src/public/index.api.ts
··· 1 1 import { Effect } from "effect"; 2 2 import type { DriverCodec, DriverProcessConfig, DriverRegistration } from "@mill/core"; 3 - 4 - const CLAUDE_MODELS: ReadonlyArray<string> = ["anthropic/claude-sonnet-4-6"]; 3 + import { makeClaudeProcessDriver } from "../process-driver.effect"; 5 4 6 5 export interface CreateClaudeDriverRegistrationInput { 7 6 readonly process?: DriverProcessConfig; 7 + readonly models?: ReadonlyArray<string>; 8 8 } 9 9 10 - export const createClaudeCodec = (): DriverCodec => ({ 11 - modelCatalog: Effect.succeed(CLAUDE_MODELS), 10 + export const createClaudeCodec = (input?: { 11 + readonly models?: ReadonlyArray<string>; 12 + }): DriverCodec => ({ 13 + modelCatalog: Effect.succeed(input?.models ?? []), 12 14 }); 13 15 14 16 export const createClaudeDriverConfig = (): DriverProcessConfig => ({ 15 17 command: "claude", 16 - args: [], 17 - env: {}, 18 + args: ["--print", "--verbose", "--output-format", "stream-json"], 19 + env: undefined, 18 20 }); 19 21 20 22 export const createClaudeDriverRegistration = ( ··· 26 28 description: "Claude process driver", 27 29 modelFormat: "provider/model-id", 28 30 process, 29 - codec: createClaudeCodec(), 30 - runtime: { 31 - name: "claude", 32 - spawn: (spawnInput) => 33 - Effect.succeed({ 34 - events: [ 35 - { 36 - type: "milestone", 37 - message: `claude:${spawnInput.agent}`, 38 - }, 39 - ], 40 - result: { 41 - text: `claude:${spawnInput.prompt}`, 42 - sessionRef: `session/claude/${spawnInput.agent}`, 43 - agent: spawnInput.agent, 44 - model: spawnInput.model, 45 - driver: "claude", 46 - exitCode: 0, 47 - stopReason: "complete", 48 - }, 49 - }), 50 - }, 31 + codec: createClaudeCodec({ 32 + models: input?.models, 33 + }), 34 + runtime: makeClaudeProcessDriver(process), 51 35 }; 52 36 };
+1
packages/driver-codex/src/codex.codec.ts
··· 1 + export * from "./internal/codex.codec";
+249
packages/driver-codex/src/internal/codex.codec.ts
··· 1 + import { Data, Effect } from "effect"; 2 + import type { DriverSpawnEvent, DriverSpawnOutput } from "@mill/core"; 3 + 4 + export class CodexCodecError extends Data.TaggedError("CodexCodecError")<{ 5 + message: string; 6 + }> {} 7 + 8 + type DecodeCodexProcessInput = { 9 + readonly agent: string; 10 + readonly model: string; 11 + readonly spawnId: string; 12 + }; 13 + 14 + type JsonRecord = Readonly<Record<string, unknown>>; 15 + 16 + const isRecord = (value: unknown): value is JsonRecord => 17 + typeof value === "object" && value !== null && !Array.isArray(value); 18 + 19 + const readString = (record: JsonRecord, key: string): string | undefined => { 20 + const value = record[key]; 21 + return typeof value === "string" ? value : undefined; 22 + }; 23 + 24 + const decodeJsonLine = (line: string): Effect.Effect<JsonRecord, CodexCodecError> => 25 + Effect.flatMap( 26 + Effect.try({ 27 + try: () => JSON.parse(line) as unknown, 28 + catch: (error) => 29 + new CodexCodecError({ 30 + message: String(error), 31 + }), 32 + }), 33 + (parsed) => 34 + isRecord(parsed) 35 + ? Effect.succeed(parsed) 36 + : Effect.fail( 37 + new CodexCodecError({ 38 + message: "line must decode to a JSON object", 39 + }), 40 + ), 41 + ); 42 + 43 + const extractToolName = (item: JsonRecord): string | undefined => { 44 + const directName = readString(item, "name") ?? readString(item, "tool_name"); 45 + 46 + if (directName !== undefined) { 47 + return directName; 48 + } 49 + 50 + const command = readString(item, "command"); 51 + 52 + if (command === undefined) { 53 + return undefined; 54 + } 55 + 56 + const [commandName] = command.split(" "); 57 + return commandName; 58 + }; 59 + 60 + const extractItemText = (item: JsonRecord): string | undefined => { 61 + const text = readString(item, "text"); 62 + 63 + if (text !== undefined) { 64 + return text; 65 + } 66 + 67 + const content = item.content; 68 + 69 + if (!Array.isArray(content)) { 70 + return undefined; 71 + } 72 + 73 + const textParts = content 74 + .map((entry) => { 75 + if (!isRecord(entry)) { 76 + return undefined; 77 + } 78 + 79 + if (readString(entry, "type") !== "text") { 80 + return undefined; 81 + } 82 + 83 + return readString(entry, "text"); 84 + }) 85 + .filter((value): value is string => value !== undefined && value.length > 0); 86 + 87 + if (textParts.length === 0) { 88 + return undefined; 89 + } 90 + 91 + return textParts.join("\n"); 92 + }; 93 + 94 + const isToolItemType = (itemType: string | undefined): boolean => 95 + itemType === "command_execution" || 96 + itemType === "function_call" || 97 + itemType === "mcp_tool_call" || 98 + itemType === "tool_call"; 99 + 100 + const isAssistantItemType = (itemType: string | undefined): boolean => 101 + itemType === "agent_message" || itemType === "assistant_message"; 102 + 103 + const isTerminalType = (eventType: string | undefined): boolean => 104 + eventType === "turn.completed" || eventType === "turn.failed" || eventType === "error"; 105 + 106 + export const decodeCodexProcessOutput = ( 107 + output: string, 108 + input: DecodeCodexProcessInput, 109 + ): Effect.Effect<DriverSpawnOutput, CodexCodecError> => 110 + Effect.gen(function* () { 111 + const lines = output 112 + .split("\n") 113 + .map((line) => line.trim()) 114 + .filter((line) => line.length > 0); 115 + 116 + const decodedLines = yield* Effect.forEach(lines, decodeJsonLine); 117 + 118 + const events: Array<DriverSpawnEvent> = []; 119 + let sessionRef: string | undefined; 120 + let responseText: string | undefined; 121 + let stopReason: string | undefined; 122 + let exitCode = 0; 123 + let errorMessage: string | undefined; 124 + let terminalSeen = false; 125 + 126 + for (const decoded of decodedLines) { 127 + const eventType = readString(decoded, "type"); 128 + 129 + if (terminalSeen && !isTerminalType(eventType)) { 130 + return yield* Effect.fail( 131 + new CodexCodecError({ 132 + message: `Non-terminal line ${eventType ?? "unknown"} emitted after terminal event.`, 133 + }), 134 + ); 135 + } 136 + 137 + if (eventType === "thread.started") { 138 + const threadId = readString(decoded, "thread_id"); 139 + 140 + if (threadId !== undefined) { 141 + sessionRef = threadId; 142 + events.push({ 143 + type: "milestone", 144 + message: "thread:start", 145 + }); 146 + } 147 + 148 + continue; 149 + } 150 + 151 + if (eventType === "item.started" || eventType === "item.completed" || eventType === "item.updated") { 152 + const item = decoded.item; 153 + 154 + if (!isRecord(item)) { 155 + continue; 156 + } 157 + 158 + const itemType = readString(item, "type"); 159 + 160 + if (isToolItemType(itemType)) { 161 + const toolName = extractToolName(item); 162 + 163 + if (toolName !== undefined && toolName.length > 0) { 164 + events.push({ 165 + type: "tool_call", 166 + toolName, 167 + }); 168 + } 169 + } 170 + 171 + if (eventType === "item.completed" && isAssistantItemType(itemType)) { 172 + const itemText = extractItemText(item); 173 + 174 + if (itemText !== undefined) { 175 + responseText = itemText; 176 + } 177 + } 178 + 179 + continue; 180 + } 181 + 182 + if (eventType === "turn.completed") { 183 + if (terminalSeen) { 184 + return yield* Effect.fail( 185 + new CodexCodecError({ 186 + message: "Duplicate terminal events are not allowed.", 187 + }), 188 + ); 189 + } 190 + 191 + terminalSeen = true; 192 + stopReason = "completed"; 193 + continue; 194 + } 195 + 196 + if (eventType === "turn.failed") { 197 + if (terminalSeen) { 198 + return yield* Effect.fail( 199 + new CodexCodecError({ 200 + message: "Duplicate terminal events are not allowed.", 201 + }), 202 + ); 203 + } 204 + 205 + terminalSeen = true; 206 + exitCode = 1; 207 + errorMessage = readString(decoded, "message") ?? "codex turn failed"; 208 + stopReason = "failed"; 209 + continue; 210 + } 211 + 212 + if (eventType === "error") { 213 + if (terminalSeen) { 214 + return yield* Effect.fail( 215 + new CodexCodecError({ 216 + message: "Duplicate terminal events are not allowed.", 217 + }), 218 + ); 219 + } 220 + 221 + terminalSeen = true; 222 + exitCode = 1; 223 + errorMessage = readString(decoded, "message") ?? "codex error"; 224 + stopReason = "error"; 225 + } 226 + } 227 + 228 + if (!terminalSeen) { 229 + return yield* Effect.fail( 230 + new CodexCodecError({ 231 + message: "Missing terminal event from codex process output.", 232 + }), 233 + ); 234 + } 235 + 236 + return { 237 + events, 238 + result: { 239 + text: responseText ?? "", 240 + sessionRef: sessionRef ?? `session/${input.spawnId}`, 241 + agent: input.agent, 242 + model: input.model, 243 + driver: "codex", 244 + exitCode, 245 + stopReason, 246 + errorMessage, 247 + }, 248 + } satisfies DriverSpawnOutput; 249 + });
+109
packages/driver-codex/src/internal/process-driver.effect.ts
··· 1 + import * as Command from "@effect/platform/Command"; 2 + import * as FileSystem from "@effect/platform/FileSystem"; 3 + import { Data, Effect } from "effect"; 4 + import type { DriverProcessConfig, DriverRuntime, DriverSpawnInput } from "@mill/core"; 5 + import { decodeCodexProcessOutput } from "./codex.codec"; 6 + 7 + export class CodexProcessDriverError extends Data.TaggedError("CodexProcessDriverError")<{ 8 + message: string; 9 + }> {} 10 + 11 + const toMessage = (error: unknown): string => { 12 + if (error instanceof Error) { 13 + return error.message; 14 + } 15 + 16 + return String(error); 17 + }; 18 + 19 + const normalizeCodexModel = (model: string): string => { 20 + const parts = model.split("/"); 21 + return parts[parts.length - 1] ?? model; 22 + }; 23 + 24 + const instructionFilePath = (input: DriverSpawnInput): string => 25 + `/tmp/mill-codex-system-${input.runId}-${input.spawnId}.md`; 26 + 27 + const commandForSpawn = ( 28 + config: DriverProcessConfig, 29 + input: DriverSpawnInput, 30 + systemPromptPath: string, 31 + ): Command.Command => { 32 + const command = Command.make( 33 + config.command, 34 + ...config.args, 35 + "--model", 36 + normalizeCodexModel(input.model), 37 + "--config", 38 + `model_instructions_file=\"${systemPromptPath}\"`, 39 + input.prompt, 40 + ).pipe(Command.stdin("ignore")); 41 + 42 + if (config.env === undefined || Object.keys(config.env).length === 0) { 43 + return command; 44 + } 45 + 46 + return Command.env(command, config.env); 47 + }; 48 + 49 + export const makeCodexProcessDriver = (config: DriverProcessConfig): DriverRuntime => ({ 50 + name: "codex", 51 + spawn: (input) => 52 + Effect.gen(function* () { 53 + const fileSystem = yield* FileSystem.FileSystem; 54 + const systemPromptPath = instructionFilePath(input); 55 + 56 + yield* Effect.mapError( 57 + fileSystem.writeFileString(systemPromptPath, input.systemPrompt), 58 + (error) => 59 + new CodexProcessDriverError({ 60 + message: `Unable to write codex instruction file: ${toMessage(error)}`, 61 + }), 62 + ); 63 + 64 + const command = commandForSpawn(config, input, systemPromptPath); 65 + 66 + const spawnEffect = Effect.gen(function* () { 67 + const stdout = yield* Effect.mapError( 68 + Command.string(command), 69 + (error) => 70 + new CodexProcessDriverError({ 71 + message: toMessage(error), 72 + }), 73 + ); 74 + 75 + const decoded = yield* Effect.mapError( 76 + decodeCodexProcessOutput(stdout, { 77 + agent: input.agent, 78 + model: input.model, 79 + spawnId: input.spawnId, 80 + }), 81 + (error) => 82 + new CodexProcessDriverError({ 83 + message: toMessage(error), 84 + }), 85 + ); 86 + 87 + const raw = stdout 88 + .split("\n") 89 + .map((line) => line.trim()) 90 + .filter((line) => line.length > 0); 91 + 92 + return { 93 + ...decoded, 94 + raw, 95 + }; 96 + }); 97 + 98 + return yield* Effect.ensuring( 99 + spawnEffect, 100 + Effect.ignore(fileSystem.remove(systemPromptPath)), 101 + ); 102 + }), 103 + resolveSession: ({ sessionRef }) => 104 + Effect.succeed({ 105 + driver: "codex", 106 + sessionRef, 107 + pointer: `codex://session/${encodeURIComponent(sessionRef)}`, 108 + }), 109 + });
+1
packages/driver-codex/src/process-driver.effect.ts
··· 1 + export * from "./internal/process-driver.effect";
+20 -4
packages/driver-codex/src/public/index.api.test.ts
··· 5 5 6 6 const runtime = Runtime.defaultRuntime; 7 7 8 + const CODEX_JSON_FIXTURE_SCRIPT = 9 + "console.log(JSON.stringify({type:'thread.started',thread_id:'codex-thread'}));" + 10 + "console.log(JSON.stringify({type:'item.completed',item:{type:'command_execution',command:'ls -la'}}));" + 11 + "console.log(JSON.stringify({type:'item.completed',item:{type:'agent_message',text:'done'}}));" + 12 + "console.log(JSON.stringify({type:'turn.completed'}));"; 13 + 8 14 describe("createCodexDriverRegistration", () => { 9 - it("exposes catalog-backed model discovery", async () => { 10 - const driver = createCodexDriverRegistration(); 15 + it("supports explicit model catalogs", async () => { 16 + const driver = createCodexDriverRegistration({ 17 + models: ["openai/gpt-5.3-codex"], 18 + }); 11 19 const models = await Runtime.runPromise(runtime)(driver.codec.modelCatalog); 12 20 13 21 expect(models).toEqual(["openai/gpt-5.3-codex"]); ··· 15 23 }); 16 24 17 25 it("spawns runtime outputs via generic driver contracts", async () => { 18 - const driver = createCodexDriverRegistration(); 26 + const driver = createCodexDriverRegistration({ 27 + process: { 28 + command: "bun", 29 + args: ["-e", CODEX_JSON_FIXTURE_SCRIPT], 30 + }, 31 + models: ["openai/gpt-5.3-codex"], 32 + }); 19 33 20 34 if (driver.runtime === undefined) { 21 35 return; ··· 36 50 ); 37 51 38 52 expect(output.result.driver).toBe("codex"); 39 - expect(output.result.sessionRef.length).toBeGreaterThan(0); 53 + expect(output.result.sessionRef).toBe("codex-thread"); 54 + expect(output.result.text).toBe("done"); 55 + expect(output.events.some((event) => event.type === "tool_call")).toBe(true); 40 56 }); 41 57 });
+12 -28
packages/driver-codex/src/public/index.api.ts
··· 1 1 import { Effect } from "effect"; 2 2 import type { DriverCodec, DriverProcessConfig, DriverRegistration } from "@mill/core"; 3 - 4 - const CODEX_MODELS: ReadonlyArray<string> = ["openai/gpt-5.3-codex"]; 3 + import { makeCodexProcessDriver } from "../process-driver.effect"; 5 4 6 5 export interface CreateCodexDriverRegistrationInput { 7 6 readonly process?: DriverProcessConfig; 7 + readonly models?: ReadonlyArray<string>; 8 8 } 9 9 10 - export const createCodexCodec = (): DriverCodec => ({ 11 - modelCatalog: Effect.succeed(CODEX_MODELS), 10 + export const createCodexCodec = (input?: { 11 + readonly models?: ReadonlyArray<string>; 12 + }): DriverCodec => ({ 13 + modelCatalog: Effect.succeed(input?.models ?? []), 12 14 }); 13 15 14 16 export const createCodexDriverConfig = (): DriverProcessConfig => ({ 15 17 command: "codex", 16 - args: [], 17 - env: {}, 18 + args: ["exec", "--json", "--skip-git-repo-check"], 19 + env: undefined, 18 20 }); 19 21 20 22 export const createCodexDriverRegistration = ( ··· 26 28 description: "Codex process driver", 27 29 modelFormat: "provider/model-id", 28 30 process, 29 - codec: createCodexCodec(), 30 - runtime: { 31 - name: "codex", 32 - spawn: (spawnInput) => 33 - Effect.succeed({ 34 - events: [ 35 - { 36 - type: "milestone", 37 - message: `codex:${spawnInput.agent}`, 38 - }, 39 - ], 40 - result: { 41 - text: `codex:${spawnInput.prompt}`, 42 - sessionRef: `session/codex/${spawnInput.agent}`, 43 - agent: spawnInput.agent, 44 - model: spawnInput.model, 45 - driver: "codex", 46 - exitCode: 0, 47 - stopReason: "complete", 48 - }, 49 - }), 50 - }, 31 + codec: createCodexCodec({ 32 + models: input?.models, 33 + }), 34 + runtime: makeCodexProcessDriver(process), 51 35 }; 52 36 };
+25 -30
packages/driver-pi/src/internal/pi.codec.test.ts
··· 4 4 import { decodePiProcessOutput } from "./pi.codec"; 5 5 6 6 describe("pi codec terminal sequencing", () => { 7 - it("rejects duplicate final lines deterministically", async () => { 7 + it("rejects duplicate terminal agent_end lines deterministically", async () => { 8 8 const output = [ 9 - JSON.stringify({ type: "milestone", message: "start" }), 10 - JSON.stringify({ 11 - type: "final", 12 - text: "done", 13 - sessionRef: "session/scout", 14 - agent: "scout", 15 - model: "openai/gpt-5.3-codex", 16 - exitCode: 0, 17 - }), 18 - JSON.stringify({ 19 - type: "final", 20 - text: "done-again", 21 - sessionRef: "session/scout", 22 - agent: "scout", 23 - model: "openai/gpt-5.3-codex", 24 - exitCode: 0, 25 - }), 9 + JSON.stringify({ type: "session", id: "session-test" }), 10 + JSON.stringify({ type: "agent_end", messages: [] }), 11 + JSON.stringify({ type: "agent_end", messages: [] }), 26 12 ].join("\n"); 27 13 28 - const decodeError = await runWithRuntime(Effect.flip(decodePiProcessOutput(output))); 14 + const decodeError = await runWithRuntime( 15 + Effect.flip( 16 + decodePiProcessOutput(output, { 17 + agent: "scout", 18 + model: "openai/gpt-5.3-codex", 19 + spawnId: "spawn_test", 20 + }), 21 + ), 22 + ); 29 23 30 24 expect(decodeError).toMatchObject({ 31 25 _tag: "PiCodecError", 32 26 }); 33 27 }); 34 28 35 - it("rejects non-terminal lines emitted after final terminal", async () => { 29 + it("rejects non-terminal lines emitted after terminal agent_end", async () => { 36 30 const output = [ 37 - JSON.stringify({ 38 - type: "final", 39 - text: "done", 40 - sessionRef: "session/scout", 41 - agent: "scout", 42 - model: "openai/gpt-5.3-codex", 43 - exitCode: 0, 44 - }), 45 - JSON.stringify({ type: "tool_call", toolName: "grep" }), 31 + JSON.stringify({ type: "agent_end", messages: [] }), 32 + JSON.stringify({ type: "tool_execution_start", toolName: "bash" }), 46 33 ].join("\n"); 47 34 48 - const decodeError = await runWithRuntime(Effect.flip(decodePiProcessOutput(output))); 35 + const decodeError = await runWithRuntime( 36 + Effect.flip( 37 + decodePiProcessOutput(output, { 38 + agent: "scout", 39 + model: "openai/gpt-5.3-codex", 40 + spawnId: "spawn_test", 41 + }), 42 + ), 43 + ); 49 44 50 45 expect(decodeError).toMatchObject({ 51 46 _tag: "PiCodecError",
+213 -76
packages/driver-pi/src/internal/pi.codec.ts
··· 1 - import * as Schema from "@effect/schema/Schema"; 2 1 import { Data, Effect } from "effect"; 3 2 import type { DriverSpawnEvent, DriverSpawnOutput } from "@mill/core"; 4 3 ··· 6 5 message: string; 7 6 }> {} 8 7 9 - const PiMilestoneLine = Schema.Struct({ 10 - type: Schema.Literal("milestone"), 11 - message: Schema.NonEmptyString, 12 - }); 8 + type DecodePiProcessInput = { 9 + readonly agent: string; 10 + readonly model: string; 11 + readonly spawnId: string; 12 + }; 13 + 14 + type JsonRecord = Readonly<Record<string, unknown>>; 15 + 16 + const isRecord = (value: unknown): value is JsonRecord => 17 + typeof value === "object" && value !== null && !Array.isArray(value); 13 18 14 - type PiMilestoneLine = Schema.Schema.Type<typeof PiMilestoneLine>; 19 + const readString = (record: JsonRecord, key: string): string | undefined => { 20 + const value = record[key]; 21 + return typeof value === "string" ? value : undefined; 22 + }; 15 23 16 - const PiToolCallLine = Schema.Struct({ 17 - type: Schema.Literal("tool_call"), 18 - toolName: Schema.NonEmptyString, 19 - }); 24 + const extractTextFromContent = (content: unknown): string | undefined => { 25 + if (!Array.isArray(content)) { 26 + return undefined; 27 + } 20 28 21 - type PiToolCallLine = Schema.Schema.Type<typeof PiToolCallLine>; 29 + const textParts = content 30 + .map((entry) => { 31 + if (!isRecord(entry)) { 32 + return undefined; 33 + } 22 34 23 - const PiFinalLine = Schema.Struct({ 24 - type: Schema.Literal("final"), 25 - text: Schema.String, 26 - sessionRef: Schema.NonEmptyString, 27 - agent: Schema.NonEmptyString, 28 - model: Schema.NonEmptyString, 29 - exitCode: Schema.Number, 30 - stopReason: Schema.optional(Schema.String), 31 - errorMessage: Schema.optional(Schema.String), 32 - }); 35 + if (readString(entry, "type") !== "text") { 36 + return undefined; 37 + } 33 38 34 - type PiFinalLine = Schema.Schema.Type<typeof PiFinalLine>; 39 + return readString(entry, "text"); 40 + }) 41 + .filter((text): text is string => text !== undefined && text.length > 0); 35 42 36 - const PiOutputLine = Schema.Union(PiMilestoneLine, PiToolCallLine, PiFinalLine); 43 + if (textParts.length === 0) { 44 + return undefined; 45 + } 37 46 38 - const decodeLine = Schema.decodeUnknown(Schema.parseJson(PiOutputLine)); 47 + return textParts.join("\n"); 48 + }; 39 49 40 - const toDriverEvent = (line: PiMilestoneLine | PiToolCallLine): DriverSpawnEvent => { 41 - if (line.type === "milestone") { 42 - return { 43 - type: "milestone", 44 - message: line.message, 45 - }; 50 + const extractAssistantSummary = (message: unknown): { text?: string; stopReason?: string } => { 51 + if (!isRecord(message)) { 52 + return {}; 46 53 } 47 54 48 55 return { 49 - type: "tool_call", 50 - toolName: line.toolName, 56 + text: extractTextFromContent(message.content), 57 + stopReason: readString(message, "stopReason"), 51 58 }; 52 59 }; 53 60 54 - const decodeFinalResult = ( 55 - finalLine: PiFinalLine | undefined, 56 - ): Effect.Effect<DriverSpawnOutput["result"], PiCodecError> => { 57 - if (finalLine === undefined) { 58 - return Effect.fail( 59 - new PiCodecError({ 60 - message: "Missing final output line from pi process", 61 - }), 62 - ); 63 - } 61 + const decodeJsonLine = (line: string): Effect.Effect<JsonRecord, PiCodecError> => 62 + Effect.flatMap( 63 + Effect.try({ 64 + try: () => JSON.parse(line) as unknown, 65 + catch: (error) => 66 + new PiCodecError({ 67 + message: String(error), 68 + }), 69 + }), 70 + (parsed) => 71 + isRecord(parsed) 72 + ? Effect.succeed(parsed) 73 + : Effect.fail( 74 + new PiCodecError({ 75 + message: "line must decode to a JSON object", 76 + }), 77 + ), 78 + ); 64 79 65 - return Effect.succeed({ 66 - text: finalLine.text, 67 - sessionRef: finalLine.sessionRef, 68 - agent: finalLine.agent, 69 - model: finalLine.model, 70 - driver: "pi", 71 - exitCode: finalLine.exitCode, 72 - stopReason: finalLine.stopReason, 73 - errorMessage: finalLine.errorMessage, 74 - }); 75 - }; 80 + const isTerminalEvent = (eventType: string | undefined): boolean => eventType === "agent_end"; 76 81 77 82 export const decodePiProcessOutput = ( 78 83 output: string, 84 + input: DecodePiProcessInput, 79 85 ): Effect.Effect<DriverSpawnOutput, PiCodecError> => 80 86 Effect.gen(function* () { 81 87 const lines = output ··· 83 89 .map((line) => line.trim()) 84 90 .filter((line) => line.length > 0); 85 91 86 - const decodedLines = yield* Effect.forEach(lines, (line) => 87 - Effect.mapError( 88 - decodeLine(line), 89 - (error) => 90 - new PiCodecError({ 91 - message: String(error), 92 - }), 93 - ), 94 - ); 92 + const decodedLines = yield* Effect.forEach(lines, decodeJsonLine); 95 93 96 - let finalLine: PiFinalLine | undefined = undefined; 97 94 const events: Array<DriverSpawnEvent> = []; 95 + let sessionRef: string | undefined; 96 + let responseText: string | undefined; 97 + let stopReason: string | undefined; 98 + let terminalSeen = false; 98 99 99 100 for (const decoded of decodedLines) { 100 - if (decoded.type === "final") { 101 - if (finalLine !== undefined) { 101 + const eventType = readString(decoded, "type"); 102 + 103 + if (terminalSeen && !isTerminalEvent(eventType)) { 104 + return yield* Effect.fail( 105 + new PiCodecError({ 106 + message: `Non-terminal line ${eventType ?? "unknown"} emitted after terminal agent_end.`, 107 + }), 108 + ); 109 + } 110 + 111 + if (isTerminalEvent(eventType)) { 112 + if (terminalSeen) { 102 113 return yield* Effect.fail( 103 114 new PiCodecError({ 104 - message: "Duplicate terminal final lines are not allowed.", 115 + message: "Duplicate terminal agent_end lines are not allowed.", 105 116 }), 106 117 ); 107 118 } 108 119 109 - finalLine = decoded; 120 + terminalSeen = true; 121 + 122 + const messages = decoded.messages; 123 + 124 + if (Array.isArray(messages)) { 125 + const assistantMessages = messages 126 + .filter((entry): entry is JsonRecord => isRecord(entry)) 127 + .filter((entry) => readString(entry, "role") === "assistant"); 128 + 129 + const lastAssistantMessage = assistantMessages[assistantMessages.length - 1]; 130 + const assistantSummary = extractAssistantSummary(lastAssistantMessage); 131 + 132 + if (assistantSummary.text !== undefined) { 133 + responseText = assistantSummary.text; 134 + } 135 + 136 + if (assistantSummary.stopReason !== undefined) { 137 + stopReason = assistantSummary.stopReason; 138 + } 139 + } 140 + 110 141 continue; 111 142 } 112 143 113 - if (finalLine !== undefined) { 114 - return yield* Effect.fail( 115 - new PiCodecError({ 116 - message: `Non-terminal line ${decoded.type} emitted after final terminal line.`, 117 - }), 118 - ); 144 + if (eventType === "session") { 145 + const id = readString(decoded, "id"); 146 + 147 + if (id !== undefined) { 148 + sessionRef = id; 149 + events.push({ 150 + type: "milestone", 151 + message: "session:start", 152 + }); 153 + } 154 + 155 + continue; 119 156 } 120 157 121 - events.push(toDriverEvent(decoded)); 158 + if (eventType === "agent_start") { 159 + events.push({ 160 + type: "milestone", 161 + message: "agent:start", 162 + }); 163 + continue; 164 + } 165 + 166 + if (eventType === "turn_start") { 167 + events.push({ 168 + type: "milestone", 169 + message: "turn:start", 170 + }); 171 + continue; 172 + } 173 + 174 + if (eventType === "tool_execution_start") { 175 + const toolName = readString(decoded, "toolName"); 176 + 177 + if (toolName !== undefined) { 178 + events.push({ 179 + type: "tool_call", 180 + toolName, 181 + }); 182 + } 183 + 184 + continue; 185 + } 186 + 187 + if (eventType === "message_end") { 188 + const message = decoded.message; 189 + 190 + if (!isRecord(message) || readString(message, "role") !== "assistant") { 191 + continue; 192 + } 193 + 194 + const assistantSummary = extractAssistantSummary(message); 195 + 196 + if (assistantSummary.text !== undefined) { 197 + responseText = assistantSummary.text; 198 + } 199 + 200 + if (assistantSummary.stopReason !== undefined) { 201 + stopReason = assistantSummary.stopReason; 202 + } 203 + } 122 204 } 123 205 124 - const result = yield* decodeFinalResult(finalLine); 206 + if (!terminalSeen) { 207 + return yield* Effect.fail( 208 + new PiCodecError({ 209 + message: "Missing terminal agent_end line from pi process output.", 210 + }), 211 + ); 212 + } 125 213 126 214 return { 127 215 events, 128 - result, 216 + result: { 217 + text: responseText ?? "", 218 + sessionRef: sessionRef ?? `session/${input.spawnId}`, 219 + agent: input.agent, 220 + model: input.model, 221 + driver: "pi", 222 + exitCode: 0, 223 + stopReason, 224 + }, 129 225 } satisfies DriverSpawnOutput; 130 226 }); 227 + 228 + export const decodePiModelCatalogOutput = ( 229 + output: string, 230 + ): Effect.Effect<ReadonlyArray<string>, PiCodecError> => 231 + Effect.gen(function* () { 232 + const lines = output 233 + .split("\n") 234 + .map((line) => line.trim()) 235 + .filter((line) => line.length > 0); 236 + 237 + const models = new Set<string>(); 238 + 239 + for (const line of lines) { 240 + if (line.startsWith("provider") || line.startsWith("-")) { 241 + continue; 242 + } 243 + 244 + const match = /^(\S+)\s+(\S+)\s+/.exec(line); 245 + 246 + if (match === null) { 247 + continue; 248 + } 249 + 250 + const provider = match[1]; 251 + const model = match[2]; 252 + 253 + if (provider !== undefined && model !== undefined) { 254 + models.add(`${provider}/${model}`); 255 + } 256 + } 257 + 258 + if (models.size === 0) { 259 + return yield* Effect.fail( 260 + new PiCodecError({ 261 + message: "No models found in pi --list-models output.", 262 + }), 263 + ); 264 + } 265 + 266 + return Array.from(models); 267 + });
+19 -10
packages/driver-pi/src/internal/process-driver.effect.ts
··· 16 16 }; 17 17 18 18 const commandForSpawn = (config: DriverProcessConfig, input: DriverSpawnInput): Command.Command => { 19 - const payload = JSON.stringify({ 20 - runId: input.runId, 21 - spawnId: input.spawnId, 22 - agent: input.agent, 23 - systemPrompt: input.systemPrompt, 24 - prompt: input.prompt, 25 - model: input.model, 26 - }); 19 + const command = Command.make( 20 + config.command, 21 + ...config.args, 22 + "--model", 23 + input.model, 24 + "--system-prompt", 25 + input.systemPrompt, 26 + input.prompt, 27 + ).pipe(Command.stdin("ignore")); 28 + 29 + if (config.env === undefined || Object.keys(config.env).length === 0) { 30 + return command; 31 + } 27 32 28 - return Command.env(Command.make(config.command, ...config.args, payload), config.env ?? {}); 33 + return Command.env(command, config.env); 29 34 }; 30 35 31 36 export const makePiProcessDriver = (config: DriverProcessConfig): DriverRuntime => ({ ··· 42 47 ); 43 48 44 49 const decoded = yield* Effect.mapError( 45 - decodePiProcessOutput(stdout), 50 + decodePiProcessOutput(stdout, { 51 + agent: input.agent, 52 + model: input.model, 53 + spawnId: input.spawnId, 54 + }), 46 55 (error) => 47 56 new PiProcessDriverError({ 48 57 message: toMessage(error),
+33 -12
packages/driver-pi/src/public/index.api.test.ts
··· 5 5 6 6 const runtime = Runtime.defaultRuntime; 7 7 8 + const PI_JSON_FIXTURE_SCRIPT = 9 + "const args=process.argv.slice(1);" + 10 + "const modelIndex=args.indexOf('--model');" + 11 + "const model=modelIndex>=0?args[modelIndex+1]:'openai/gpt-5.3-codex';" + 12 + "const prompt=args[args.length-1]??'';" + 13 + "console.log(JSON.stringify({type:'session',id:'session-test'}));" + 14 + "console.log(JSON.stringify({type:'agent_start'}));" + 15 + "console.log(JSON.stringify({type:'tool_execution_start',toolName:'bash'}));" + 16 + "console.log(JSON.stringify({type:'message_end',message:{role:'assistant',content:[{type:'text',text:'fixture:'+prompt}],model,stopReason:'stop'}}));" + 17 + "console.log(JSON.stringify({type:'agent_end',messages:[{role:'assistant',content:[{type:'text',text:'fixture:'+prompt}],model,stopReason:'stop'}]}));"; 18 + 8 19 const DUPLICATE_TERMINAL_SCRIPT = 9 - "const input=JSON.parse(process.argv[1]);" + 10 - "console.log(JSON.stringify({type:'milestone',message:'spawn:'+input.agent}));" + 11 - "console.log(JSON.stringify({type:'final',text:'ok',sessionRef:'session/'+input.agent,agent:input.agent,model:input.model,exitCode:0}));" + 12 - "console.log(JSON.stringify({type:'final',text:'duplicate',sessionRef:'session/'+input.agent,agent:input.agent,model:input.model,exitCode:0}));"; 20 + "console.log(JSON.stringify({type:'agent_end',messages:[]}));" + 21 + "console.log(JSON.stringify({type:'agent_end',messages:[]}));"; 13 22 14 23 describe("createPiDriverRegistration", () => { 15 - it("exposes catalog-backed model discovery via codec", async () => { 16 - const driver = createPiDriverRegistration(); 24 + it("supports explicit model catalog overrides via codec", async () => { 25 + const driver = createPiDriverRegistration({ 26 + models: ["openai/gpt-5.3-codex"], 27 + }); 17 28 18 29 const models = await Runtime.runPromise(runtime)(driver.codec.modelCatalog); 19 30 20 - expect(models).toEqual(["openai/gpt-5.3-codex", "anthropic/claude-sonnet-4-6"]); 31 + expect(models).toEqual(["openai/gpt-5.3-codex"]); 21 32 expect(driver.process.command.length).toBeGreaterThan(0); 22 33 expect(driver.process.args.length).toBeGreaterThan(0); 23 34 }); 24 35 25 - it("spawns process-backed runs and decodes structured result payload", async () => { 26 - const driver = createPiDriverRegistration(); 36 + it("spawns process-backed runs and decodes structured pi JSON output", async () => { 37 + const driver = createPiDriverRegistration({ 38 + process: { 39 + command: "bun", 40 + args: ["-e", PI_JSON_FIXTURE_SCRIPT], 41 + }, 42 + models: ["openai/gpt-5.3-codex"], 43 + }); 27 44 28 45 expect(driver.runtime).toBeDefined(); 29 46 ··· 45 62 ), 46 63 ); 47 64 48 - expect(output.events.length).toBeGreaterThan(0); 49 - expect(output.result.sessionRef.length).toBeGreaterThan(0); 65 + expect(output.events.some((event) => event.type === "tool_call")).toBe(true); 66 + expect(output.result.sessionRef).toBe("session-test"); 50 67 expect(output.result.agent).toBe("scout"); 51 68 expect(output.result.model).toBe("openai/gpt-5.3-codex"); 69 + expect(output.result.text).toBe("fixture:Say hello"); 52 70 expect(output.result.exitCode).toBe(0); 53 71 }); 54 72 55 73 it("resolves session pointers for inspect --session bridge", async () => { 56 - const driver = createPiDriverRegistration(); 74 + const driver = createPiDriverRegistration({ 75 + models: ["openai/gpt-5.3-codex"], 76 + }); 57 77 58 78 expect(driver.runtime).toBeDefined(); 59 79 ··· 85 105 command: "bun", 86 106 args: ["-e", DUPLICATE_TERMINAL_SCRIPT], 87 107 }, 108 + models: ["openai/gpt-5.3-codex"], 88 109 }); 89 110 90 111 expect(driver.runtime).toBeDefined();
+55 -15
packages/driver-pi/src/public/index.api.ts
··· 1 + import * as Command from "@effect/platform/Command"; 2 + import * as BunContext from "@effect/platform-bun/BunContext"; 1 3 import { Effect } from "effect"; 2 4 import type { DriverCodec, DriverProcessConfig, DriverRegistration } from "@mill/core"; 5 + import { decodePiModelCatalogOutput } from "../pi.codec"; 3 6 import { makePiProcessDriver } from "../process-driver.effect"; 4 7 5 - const PI_MODELS: ReadonlyArray<string> = ["openai/gpt-5.3-codex", "anthropic/claude-sonnet-4-6"]; 6 - 7 - const DEFAULT_PI_PROCESS_SCRIPT = 8 - "const input=JSON.parse(process.argv[1]);" + 9 - "console.log(JSON.stringify({type:'milestone',message:'spawn:'+input.agent}));" + 10 - "console.log(JSON.stringify({type:'final',text:'pi:'+input.prompt,sessionRef:'session/'+input.agent,agent:input.agent,model:input.model,exitCode:0,stopReason:'complete'}));"; 11 - 12 8 export interface CreatePiDriverRegistrationInput { 13 9 readonly process?: DriverProcessConfig; 10 + readonly models?: ReadonlyArray<string>; 14 11 } 15 12 16 - export const createPiCodec = (): DriverCodec => ({ 17 - modelCatalog: Effect.succeed(PI_MODELS), 18 - }); 13 + const defaultModelCatalog = ( 14 + process: DriverProcessConfig, 15 + ): Effect.Effect<ReadonlyArray<string>, never> => { 16 + if (process.command !== "pi") { 17 + return Effect.succeed([]); 18 + } 19 + 20 + const listModelsEffect = Effect.provide( 21 + Command.string(Command.make(process.command, "--list-models")), 22 + BunContext.layer, 23 + ); 24 + 25 + return Effect.catchAll( 26 + Effect.flatMap(listModelsEffect, decodePiModelCatalogOutput), 27 + () => Effect.succeed([]), 28 + ); 29 + }; 30 + 31 + export const createPiCodec = (input?: { 32 + readonly process?: DriverProcessConfig; 33 + readonly models?: ReadonlyArray<string>; 34 + }): DriverCodec => { 35 + if (input?.models !== undefined) { 36 + return { 37 + modelCatalog: Effect.succeed(input.models), 38 + }; 39 + } 40 + 41 + const process = input?.process ?? createPiDriverConfig(); 42 + 43 + return { 44 + modelCatalog: defaultModelCatalog(process), 45 + }; 46 + }; 19 47 20 48 export const createPiDriverConfig = (): DriverProcessConfig => ({ 21 - command: "bun", 22 - args: ["-e", DEFAULT_PI_PROCESS_SCRIPT], 23 - env: {}, 49 + command: "pi", 50 + args: [ 51 + "--mode", 52 + "json", 53 + "--print", 54 + "--no-session", 55 + "--no-extensions", 56 + "--no-skills", 57 + "--no-prompt-templates", 58 + "--no-themes", 59 + ], 60 + env: undefined, 24 61 }); 25 62 26 63 export const createPiDriverRegistration = ( ··· 29 66 const process = input?.process ?? createPiDriverConfig(); 30 67 31 68 return { 32 - description: "Local process driver", 69 + description: "PI process driver", 33 70 modelFormat: "provider/model-id", 34 71 process, 35 - codec: createPiCodec(), 72 + codec: createPiCodec({ 73 + process, 74 + models: input?.models, 75 + }), 36 76 runtime: makePiProcessDriver(process), 37 77 }; 38 78 };