programmatic subagents
0
fork

Configure Feed

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

feat(drivers+cli): surface driver model catalogs in help and harden defaults

+220 -36
+2
README.md
··· 50 50 51 51 All commands accept `--json` for machine-readable output on stdout (diagnostics go to stderr). 52 52 53 + `mill --help` and `mill <command> --help` include a **Models** section. Those model lists are sourced from each configured driver's `codec.modelCatalog` effect (via resolved `mill.config.ts`), so driver registrations are what inform the CLI/main agent about available models. 54 + 53 55 ## FAQ 54 56 55 57 **Couldn't I just do this with bash and claude -p?**
+8
packages/cli/src/public/index.api.test.ts
··· 847 847 848 848 expect(code).toBe(0); 849 849 expect(stderr).toHaveLength(0); 850 + expect(stdout[0]).toContain("Models:"); 851 + expect(stdout[0]).toContain("codex (provider/model-id): openai-codex/gpt-5.3-codex"); 850 852 expect(stdout[0]).toContain("Authoring:\n CUSTOM_AUTHORING_INSTRUCTIONS"); 851 853 expect(stdout[0]).not.toContain("systemPrompt = WHO the agent is"); 852 854 }); ··· 876 878 877 879 expect(code).toBe(0); 878 880 expect(stderr).toHaveLength(0); 881 + expect(stdout[0]).toContain("Models:"); 882 + expect(stdout[0]).toContain("claude (provider/model-id): anthropic/claude-sonnet-4-6"); 879 883 expect(stdout[0]).toContain("systemPrompt = WHO the agent is"); 880 884 expect(stdout[0]).toContain("prompt = WHAT to do now"); 881 885 expect(stdout[0]).not.toContain("From config:"); ··· 911 915 expect(stdout.join("\n")).toContain( 912 916 "Authoring (from config): CUSTOM_AUTHORING_IN_COMMAND_HELP", 913 917 ); 918 + expect(stdout.join("\n")).toContain("Models:"); 919 + expect(stdout.join("\n")).toContain("codex (provider/model-id): openai-codex/gpt-5.3-codex"); 914 920 expect(stdout.join("\n")).not.toContain("systemPrompt = WHO the agent is"); 915 921 }); 916 922 ··· 940 946 expect(code).toBe(0); 941 947 expect(stderr).toHaveLength(0); 942 948 expect(stdout.join("\n")).toContain("Authoring:\n systemPrompt = WHO the agent is"); 949 + expect(stdout.join("\n")).toContain("Models:"); 950 + expect(stdout.join("\n")).toContain("claude (provider/model-id): anthropic/claude-sonnet-4-6"); 943 951 expect(stdout.join("\n")).not.toContain("Authoring (from config):"); 944 952 }); 945 953
+85 -20
packages/cli/src/public/index.api.ts
··· 710 710 " prompt = WHAT to do now (specific files, concrete task)", 711 711 ] as const; 712 712 713 + interface DriverModelCatalogEntry { 714 + readonly driverName: string; 715 + readonly modelFormat: string; 716 + readonly models: ReadonlyArray<string>; 717 + } 718 + 713 719 type ResolvedAuthoringHelp = 714 720 | { readonly source: "static" } 715 721 | { readonly source: "config"; readonly instructions: string }; 716 722 723 + type ResolvedModelCatalogHelp = 724 + | { readonly source: "resolved"; readonly entries: ReadonlyArray<DriverModelCatalogEntry> } 725 + | { readonly source: "unavailable" }; 726 + 727 + interface ResolvedHelpContext { 728 + readonly authoring: ResolvedAuthoringHelp; 729 + readonly modelCatalog: ResolvedModelCatalogHelp; 730 + } 731 + 717 732 const renderAuthoringHelp = (authoringHelp: ResolvedAuthoringHelp): string => 718 733 authoringHelp.source === "config" 719 734 ? `Authoring:\n ${authoringHelp.instructions}` 720 735 : `Authoring:\n${STATIC_AUTHORING_HELP_LINES.join("\n")}`; 721 736 722 - const buildHelpText = (authoringHelp: ResolvedAuthoringHelp): string => 737 + const renderModelCatalogHelp = (modelCatalog: ResolvedModelCatalogHelp): string => { 738 + if (modelCatalog.source === "unavailable") { 739 + return "Models:\n (unavailable: failed to resolve config or driver catalogs)"; 740 + } 741 + 742 + if (modelCatalog.entries.length === 0) { 743 + return "Models:\n (no drivers configured)"; 744 + } 745 + 746 + return [ 747 + "Models:", 748 + ...modelCatalog.entries.map((entry) => { 749 + if (entry.models.length === 0) { 750 + return ` ${entry.driverName} (${entry.modelFormat}): (catalog empty)`; 751 + } 752 + 753 + return ` ${entry.driverName} (${entry.modelFormat}): ${entry.models.join(", ")}`; 754 + }), 755 + ].join("\n"); 756 + }; 757 + 758 + const buildHelpText = (helpContext: ResolvedHelpContext): string => 723 759 `mill - orchestration runtime for AI agents 724 760 725 761 Usage: mill <command> [options] ··· 734 770 init [--global] Create starter config (local or ~/.mill/config.ts) 735 771 736 772 Global options: --json, --driver <name>, --runs-dir <path> 773 + 774 + ${renderModelCatalogHelp(helpContext.modelCatalog)} 737 775 738 776 Examples: 739 777 ··· 755 793 mill.spawn({ agent: "perf", systemPrompt: "...", prompt: "Profile src/api/" }), 756 794 ]); 757 795 758 - ${renderAuthoringHelp(authoringHelp)} 796 + ${renderAuthoringHelp(helpContext.authoring)} 759 797 760 798 Run mill <command> --help for details.`; 761 799 ··· 788 826 return argv.slice(1).some((argument) => HELP_FLAGS.has(argument)); 789 827 }; 790 828 791 - const resolveAuthoringHelpForHelp = async ( 792 - options: RunCliOptions, 793 - ): Promise<ResolvedAuthoringHelp> => { 829 + const resolveHelpContextForHelp = async (options: RunCliOptions): Promise<ResolvedHelpContext> => { 794 830 try { 795 831 const resolvedConfig = await resolveConfig({ 796 832 defaults: defaultConfig, ··· 804 840 const hasAuthoringOverride = 805 841 resolvedConfig.source !== "defaults" && instructions !== defaultConfig.authoring.instructions; 806 842 807 - if (hasAuthoringOverride) { 808 - return { 809 - source: "config", 810 - instructions, 811 - }; 812 - } 843 + const driverEntriesUnsorted = await Runtime.runPromise(runtime)( 844 + Effect.forEach(Object.entries(resolvedConfig.config.drivers), ([driverName, registration]) => 845 + Effect.map(registration.codec.modelCatalog, (models) => ({ 846 + driverName, 847 + modelFormat: registration.modelFormat, 848 + models: Array.from(new Set(models)), 849 + })), 850 + ), 851 + ); 852 + 853 + const driverEntries = [...driverEntriesUnsorted].sort((left, right) => 854 + left.driverName.localeCompare(right.driverName), 855 + ); 856 + 857 + return { 858 + authoring: hasAuthoringOverride 859 + ? { 860 + source: "config", 861 + instructions, 862 + } 863 + : { 864 + source: "static", 865 + }, 866 + modelCatalog: { 867 + source: "resolved", 868 + entries: driverEntries, 869 + }, 870 + }; 813 871 } catch { 814 - // fall through to static authoring help 872 + // fall through to static authoring + unavailable model catalogs 815 873 } 816 874 817 875 return { 818 - source: "static", 876 + authoring: { 877 + source: "static", 878 + }, 879 + modelCatalog: { 880 + source: "unavailable", 881 + }, 819 882 }; 820 883 }; 821 884 ··· 827 890 const io = resolvedOptions.io ?? defaultIo; 828 891 829 892 if (isHelpRequest(argv)) { 830 - const authoringHelp = await resolveAuthoringHelpForHelp(resolvedOptions); 831 - io.stdout(buildHelpText(authoringHelp)); 893 + const helpContext = await resolveHelpContextForHelp(resolvedOptions); 894 + io.stdout(buildHelpText(helpContext)); 832 895 return 0; 833 896 } 834 897 835 898 const commandHelpRequest = isCommandHelpRequest(argv); 836 - const authoringHelp = commandHelpRequest 837 - ? await resolveAuthoringHelpForHelp(resolvedOptions) 899 + const helpContext = commandHelpRequest 900 + ? await resolveHelpContextForHelp(resolvedOptions) 838 901 : undefined; 839 902 840 903 const command = createCli(resolvedOptions, io); ··· 859 922 const compactHelp = CliConfig.layer({ showBuiltIns: false, showTypes: false }); 860 923 const exitCode = await runWithBunContext(Effect.provide(codeEffect, compactHelp)); 861 924 862 - if (commandHelpRequest && exitCode === 0 && authoringHelp !== undefined) { 863 - if (authoringHelp.source === "config") { 864 - io.stdout(`Authoring (from config): ${authoringHelp.instructions}`); 925 + if (commandHelpRequest && exitCode === 0 && helpContext !== undefined) { 926 + if (helpContext.authoring.source === "config") { 927 + io.stdout(`Authoring (from config): ${helpContext.authoring.instructions}`); 865 928 } else { 866 929 io.stdout(`Authoring:\n${STATIC_AUTHORING_HELP_LINES.join("\n")}`); 867 930 } 931 + 932 + io.stdout(renderModelCatalogHelp(helpContext.modelCatalog)); 868 933 } 869 934 870 935 return exitCode;
+8 -1
packages/driver-claude/src/public/index.api.test.ts
··· 13 13 describe("createClaudeDriverRegistration", () => { 14 14 it("supports explicit model catalogs", async () => { 15 15 const driver = createClaudeDriverRegistration({ 16 - models: ["anthropic/claude-sonnet-4-6"], 16 + models: [" anthropic/claude-sonnet-4-6 ", "anthropic/claude-sonnet-4-6", ""], 17 17 }); 18 18 const models = await Runtime.runPromise(runtime)(driver.codec.modelCatalog); 19 19 20 20 expect(models).toEqual(["anthropic/claude-sonnet-4-6"]); 21 21 expect(driver.runtime).toBeDefined(); 22 + }); 23 + 24 + it("provides a non-empty default catalog", async () => { 25 + const driver = createClaudeDriverRegistration(); 26 + const models = await Runtime.runPromise(runtime)(driver.codec.modelCatalog); 27 + 28 + expect(models).toContain("anthropic/claude-sonnet-4-6"); 22 29 }); 23 30 24 31 it("spawns runtime outputs via generic driver contracts", async () => {
+6 -1
packages/driver-claude/src/public/index.api.ts
··· 7 7 readonly models?: ReadonlyArray<string>; 8 8 } 9 9 10 + const DEFAULT_CLAUDE_MODELS = ["anthropic/claude-sonnet-4-6", "anthropic/claude-opus-4-6"] as const; 11 + 12 + const normalizeModelCatalog = (models: ReadonlyArray<string>): ReadonlyArray<string> => 13 + Array.from(new Set(models.map((model) => model.trim()).filter((model) => model.length > 0))); 14 + 10 15 export const createClaudeCodec = (input?: { 11 16 readonly models?: ReadonlyArray<string>; 12 17 }): DriverCodec => ({ 13 - modelCatalog: Effect.succeed(input?.models ?? []), 18 + modelCatalog: Effect.succeed(normalizeModelCatalog(input?.models ?? DEFAULT_CLAUDE_MODELS)), 14 19 }); 15 20 16 21 export const createClaudeDriverConfig = (): DriverProcessConfig => ({
+8 -1
packages/driver-codex/src/public/index.api.test.ts
··· 14 14 describe("createCodexDriverRegistration", () => { 15 15 it("supports explicit model catalogs", async () => { 16 16 const driver = createCodexDriverRegistration({ 17 - models: ["openai/gpt-5.3-codex"], 17 + models: [" openai/gpt-5.3-codex ", "openai/gpt-5.3-codex", ""], 18 18 }); 19 19 const models = await Runtime.runPromise(runtime)(driver.codec.modelCatalog); 20 20 21 21 expect(models).toEqual(["openai/gpt-5.3-codex"]); 22 22 expect(driver.runtime).toBeDefined(); 23 + }); 24 + 25 + it("provides a non-empty default catalog", async () => { 26 + const driver = createCodexDriverRegistration(); 27 + const models = await Runtime.runPromise(runtime)(driver.codec.modelCatalog); 28 + 29 + expect(models).toContain("openai-codex/gpt-5.3-codex"); 23 30 }); 24 31 25 32 it("spawns runtime outputs via generic driver contracts", async () => {
+6 -1
packages/driver-codex/src/public/index.api.ts
··· 7 7 readonly models?: ReadonlyArray<string>; 8 8 } 9 9 10 + const DEFAULT_CODEX_MODELS = ["openai-codex/gpt-5.3-codex"] as const; 11 + 12 + const normalizeModelCatalog = (models: ReadonlyArray<string>): ReadonlyArray<string> => 13 + Array.from(new Set(models.map((model) => model.trim()).filter((model) => model.length > 0))); 14 + 10 15 export const createCodexCodec = (input?: { 11 16 readonly models?: ReadonlyArray<string>; 12 17 }): DriverCodec => ({ 13 - modelCatalog: Effect.succeed(input?.models ?? []), 18 + modelCatalog: Effect.succeed(normalizeModelCatalog(input?.models ?? DEFAULT_CODEX_MODELS)), 14 19 }); 15 20 16 21 export const createCodexDriverConfig = (): DriverProcessConfig => ({
+46 -1
packages/driver-pi/src/public/index.api.test.ts
··· 1 1 import { describe, expect, it } from "bun:test"; 2 + import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; 3 + import { tmpdir } from "node:os"; 4 + import { dirname, join } from "node:path"; 2 5 import * as BunContext from "@effect/platform-bun/BunContext"; 3 6 import { Effect, Runtime } from "effect"; 4 7 import { createPiDriverRegistration } from "./index.api"; ··· 28 31 describe("createPiDriverRegistration", () => { 29 32 it("supports explicit model catalog overrides via codec", async () => { 30 33 const driver = createPiDriverRegistration({ 31 - models: ["openai/gpt-5.3-codex"], 34 + models: [" openai/gpt-5.3-codex ", "openai/gpt-5.3-codex", ""], 32 35 }); 33 36 34 37 const models = await Runtime.runPromise(runtime)(driver.codec.modelCatalog); ··· 36 39 expect(models).toEqual(["openai/gpt-5.3-codex"]); 37 40 expect(driver.process.command.length).toBeGreaterThan(0); 38 41 expect(driver.process.args.length).toBeGreaterThan(0); 42 + }); 43 + 44 + it("reads model catalog from ~/.pi/agent/settings.json when override is omitted", async () => { 45 + const tempDirectory = await mkdtemp(join(tmpdir(), "mill-driver-pi-model-catalog-")); 46 + const homeDirectory = join(tempDirectory, "home"); 47 + const settingsPath = join(homeDirectory, ".pi", "agent", "settings.json"); 48 + const previousHome = process.env.HOME; 49 + 50 + try { 51 + await mkdir(dirname(settingsPath), { recursive: true }); 52 + await writeFile( 53 + settingsPath, 54 + JSON.stringify( 55 + { 56 + enabledModels: [ 57 + " openai/gpt-5.3-codex ", 58 + "cerebras/zai-glm-4.7", 59 + "", 60 + "cerebras/zai-glm-4.7", 61 + ], 62 + }, 63 + null, 64 + 2, 65 + ), 66 + "utf-8", 67 + ); 68 + 69 + process.env.HOME = homeDirectory; 70 + 71 + const driver = createPiDriverRegistration(); 72 + const models = await Runtime.runPromise(runtime)(driver.codec.modelCatalog); 73 + 74 + expect(models).toEqual(["openai/gpt-5.3-codex", "cerebras/zai-glm-4.7"]); 75 + } finally { 76 + if (previousHome === undefined) { 77 + delete process.env.HOME; 78 + } else { 79 + process.env.HOME = previousHome; 80 + } 81 + 82 + await rm(tempDirectory, { recursive: true, force: true }); 83 + } 39 84 }); 40 85 41 86 it("spawns process-backed runs and decodes structured pi JSON output", async () => {
+51 -11
packages/driver-pi/src/public/index.api.ts
··· 1 + import * as fs from "node:fs"; 2 + import * as path from "node:path"; 1 3 import { Effect } from "effect"; 2 4 import type { DriverCodec, DriverProcessConfig, DriverRegistration } from "@mill/core"; 3 5 import { makePiProcessDriver } from "../process-driver.effect"; ··· 7 9 readonly models?: ReadonlyArray<string>; 8 10 } 9 11 10 - export const createPiCodec = (input?: { 11 - readonly process?: DriverProcessConfig; 12 - readonly models?: ReadonlyArray<string>; 13 - }): DriverCodec => { 14 - if (input?.models !== undefined) { 15 - return { 16 - modelCatalog: Effect.succeed(input.models), 17 - }; 12 + const normalizeModelCatalog = (models: ReadonlyArray<string>): ReadonlyArray<string> => 13 + Array.from(new Set(models.map((model) => model.trim()).filter((model) => model.length > 0))); 14 + 15 + const isRecord = (value: unknown): value is Record<string, unknown> => 16 + typeof value === "object" && value !== null; 17 + 18 + const readStringArrayField = ( 19 + record: Record<string, unknown>, 20 + key: string, 21 + ): ReadonlyArray<string> | undefined => { 22 + const value = record[key]; 23 + 24 + if (!Array.isArray(value)) { 25 + return undefined; 18 26 } 19 27 20 - return { 21 - modelCatalog: Effect.succeed([]), 22 - }; 28 + return value.filter((entry): entry is string => typeof entry === "string"); 23 29 }; 30 + 31 + const readPiEnabledModels = (): ReadonlyArray<string> => { 32 + const home = process.env.HOME; 33 + 34 + if (home === undefined || home.length === 0) { 35 + return []; 36 + } 37 + 38 + const settingsPath = path.join(home, ".pi", "agent", "settings.json"); 39 + 40 + if (!fs.existsSync(settingsPath)) { 41 + return []; 42 + } 43 + 44 + try { 45 + const raw = fs.readFileSync(settingsPath, "utf8"); 46 + const parsed = JSON.parse(raw) as unknown; 47 + 48 + if (!isRecord(parsed)) { 49 + return []; 50 + } 51 + 52 + return normalizeModelCatalog(readStringArrayField(parsed, "enabledModels") ?? []); 53 + } catch { 54 + return []; 55 + } 56 + }; 57 + 58 + export const createPiCodec = (input?: { 59 + readonly process?: DriverProcessConfig; 60 + readonly models?: ReadonlyArray<string>; 61 + }): DriverCodec => ({ 62 + modelCatalog: Effect.succeed(normalizeModelCatalog(input?.models ?? readPiEnabledModels())), 63 + }); 24 64 25 65 export const createPiDriverConfig = (): DriverProcessConfig => ({ 26 66 command: "pi",