programmatic subagents
0
fork

Configure Feed

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

feat(core): enforce maxRunDepth recursion guard via MILL_RUN_DEPTH

+204 -12
+50
README.md
··· 6 6 7 7 You talk to your main agent (in Pi, Claude Code, OpenCode etc.). When work needs to be farmed out, it writes a mill program: a TypeScript file that spawns subagents with specific instructions. You see the code before it executes. 8 8 9 + ## Install 10 + 11 + ```bash 12 + brew install laulauland/tap/mill 13 + ``` 14 + 15 + Or build from source (requires [Bun](https://bun.sh)): 16 + 17 + ```bash 18 + git clone https://github.com/laulauland/mill.git && cd mill 19 + bun install 20 + bun build --compile packages/cli/src/bin/mill.ts --outfile mill 21 + mv mill ~/.local/bin/ # or anywhere on your PATH 22 + ``` 23 + 24 + Then scaffold a config: 25 + 26 + ```bash 27 + mill init # creates ./mill.config.ts in current project 28 + mill init --global # creates ~/.mill/config.ts (shared default) 29 + ``` 30 + 31 + The config sets your default driver, model preferences, and authoring guidance. See [Configuration](#configuration) for details. 32 + 9 33 ## Quick example 10 34 11 35 ```ts ··· 52 76 53 77 `mill --help` and `mill <command> --help` include a **Models** section for the selected driver (`defaultDriver` from resolved config, or `--driver` override on command help). The list is sourced from that driver's `codec.modelCatalog`, so driver registration is what informs the CLI/main agent about available models. 54 78 79 + ## Use with Claude Code 80 + 81 + [Install mill](#install), then add the skill: 82 + 83 + ```bash 84 + npx skills add laulauland/mill 85 + ``` 86 + 87 + This teaches Claude Code how to write and run mill programs. When you ask it to farm out work to subagents, it will author a `.ts` program using `mill.spawn()`, show it to you for confirmation, and execute it via the CLI. 88 + 89 + ## Use with pi 90 + 91 + [Install mill](#install), then add the [pi-mill](https://github.com/laulauland/mill/tree/main/packages/pi-mill) extension: 92 + 93 + ```bash 94 + pi install npm:pi-mill 95 + ``` 96 + 97 + This registers a `subagent` tool in pi. When the agent needs to delegate work, it writes a mill program and executes it. The extension also adds monitoring: `/mill` opens an in-session overlay, and `pi --mill` launches a standalone run monitor. 98 + 55 99 ## FAQ 56 100 57 101 **Couldn't I just do this with bash and claude -p?** ··· 73 117 ``` 74 118 75 119 Resolved in order: `./mill.config.ts` → walk up to repo root → `~/.mill/config.ts` → built-in defaults. 120 + 121 + Recursion guard: 122 + 123 + - `maxRunDepth` (default `1`) limits nested `mill run` invocations by depth. 124 + - Mill tracks depth with `MILL_RUN_DEPTH` in worker/program child environments. 125 + - If a nested invocation exceeds `maxRunDepth`, `mill run` is rejected before submission. 76 126 77 127 ## Drivers 78 128
+17 -8
packages/cli/src/public/index.api.ts
··· 65 65 defaultDriver: "pi", 66 66 defaultExecutor: "direct", 67 67 defaultModel: "openai-codex/gpt-5.3-codex", 68 + maxRunDepth: 1, 68 69 drivers: { 69 70 pi: processDriver(createPiDriverRegistration()), 70 71 claude: processDriver(createClaudeDriverRegistration()), ··· 115 116 const workerPidPath = (runsDirectory: string, runId: string): string => 116 117 joinPath(joinPath(runsDirectory, runId), "worker.pid"); 117 118 119 + const RUN_DEPTH_ENV = "MILL_RUN_DEPTH"; 120 + 118 121 const buildWorkerCommandArguments = ( 119 122 hasSourceEntrypoint: boolean, 120 123 input: LaunchWorkerInput, ··· 142 145 const fileSystem = yield* FileSystem.FileSystem; 143 146 const hasSourceEntrypoint = yield* fileSystem.exists(millBinPath); 144 147 145 - const workerCommand = PlatformCommand.make( 146 - process.execPath, 147 - ...buildWorkerCommandArguments(hasSourceEntrypoint, input), 148 - ).pipe( 149 - PlatformCommand.workingDirectory(input.cwd), 150 - PlatformCommand.stdin("ignore"), 151 - PlatformCommand.stdout("ignore"), 152 - PlatformCommand.stderr("ignore"), 148 + const workerCommand = PlatformCommand.env( 149 + PlatformCommand.make( 150 + process.execPath, 151 + ...buildWorkerCommandArguments(hasSourceEntrypoint, input), 152 + ).pipe( 153 + PlatformCommand.workingDirectory(input.cwd), 154 + PlatformCommand.stdin("ignore"), 155 + PlatformCommand.stdout("ignore"), 156 + PlatformCommand.stderr("ignore"), 157 + ), 158 + { 159 + [RUN_DEPTH_ENV]: String(input.runDepth), 160 + }, 153 161 ); 154 162 155 163 const detachedScope = yield* Scope.make(); ··· 316 324 "export default {", 317 325 " // Optional: override model/driver/executor defaults.", 318 326 ' // defaultModel: "openai-codex/gpt-5.3-codex",', 327 + " // maxRunDepth: 1, // recursion guard for nested `mill run`", 319 328 " authoring: {", 320 329 ' instructions: "Use systemPrompt for WHO (role/method), prompt for WHAT (explicit task + scope + validation). Prefer codex for synthesis, cerebras for fast retrieval.",', 321 330 " },",
+2
packages/core/src/public/config-loader.api.test.ts
··· 208 208 ' defaultDriver: "module-driver",', 209 209 ' defaultExecutor: "module-executor",', 210 210 ' defaultModel: "provider/module-model",', 211 + " maxRunDepth: 3,", 211 212 " drivers: {", 212 213 " 'module-driver': processDriver({", 213 214 " description: `driver-${suffix}`,", ··· 263 264 expect(resolved.config.defaultDriver).toBe("module-driver"); 264 265 expect(resolved.config.defaultExecutor).toBe("module-executor"); 265 266 expect(resolved.config.defaultModel).toBe("provider/module-model"); 267 + expect(resolved.config.maxRunDepth).toBe(3); 266 268 expect(resolved.config.authoring.instructions).toBe("Use module config"); 267 269 expect(Object.keys(resolved.config.drivers)).toContain("default"); 268 270 expect(Object.keys(resolved.config.drivers)).toContain("module-driver");
+16
packages/core/src/public/config-loader.api.ts
··· 136 136 "defaultDriver", 137 137 "defaultExecutor", 138 138 "defaultModel", 139 + "maxRunDepth", 139 140 "drivers", 140 141 "executors", 141 142 "extensions", ··· 160 161 return typeof field === "string" ? field : undefined; 161 162 }; 162 163 164 + const readPositiveIntegerField = ( 165 + value: Record<string, unknown>, 166 + key: string, 167 + ): number | undefined => { 168 + const field = value[key]; 169 + 170 + if (!Number.isInteger(field) || (field as number) <= 0) { 171 + return undefined; 172 + } 173 + 174 + return field as number; 175 + }; 176 + 163 177 const toConfigOverrides = (value: Record<string, unknown>): ConfigFileOverrides => { 164 178 const authoringRecord = readRecordField(value, "authoring"); 165 179 ··· 167 181 defaultDriver: readStringField(value, "defaultDriver"), 168 182 defaultExecutor: readStringField(value, "defaultExecutor"), 169 183 defaultModel: readStringField(value, "defaultModel"), 184 + maxRunDepth: readPositiveIntegerField(value, "maxRunDepth"), 170 185 drivers: readRecordField(value, "drivers") as Readonly<Record<string, DriverRegistration>>, 171 186 executors: readRecordField(value, "executors") as MillConfig["executors"], 172 187 extensions: Array.isArray(value.extensions) ··· 227 242 defaultDriver: overrides.defaultDriver ?? defaults.defaultDriver, 228 243 defaultExecutor: overrides.defaultExecutor ?? defaults.defaultExecutor, 229 244 defaultModel: overrides.defaultModel ?? defaults.defaultModel, 245 + maxRunDepth: overrides.maxRunDepth ?? defaults.maxRunDepth, 230 246 drivers: { 231 247 ...defaults.drivers, 232 248 ...overrides.drivers,
+55 -1
packages/core/src/public/run.api.test.ts
··· 9 9 import { decodeRunIdSync } from "../domain/run.schema"; 10 10 import { makeRunStore } from "../internal/run-store.effect"; 11 11 import { runWithBunContext } from "./test-runtime.api"; 12 - import { cancelRun, runProgramSync, runWorker } from "./run.api"; 12 + import { cancelRun, runProgramSync, runWorker, submitRun } from "./run.api"; 13 13 import type { MillConfig } from "./types"; 14 14 15 15 const ProgramResultEnvelope = Schema.parseJson( ··· 45 45 defaultDriver: "default", 46 46 defaultExecutor: "direct", 47 47 defaultModel: "openai/gpt-5.3-codex", 48 + maxRunDepth: 1, 48 49 drivers: { 49 50 default: { 50 51 description: "default driver", ··· 230 231 expect(hostMarker).toContain("process-host:bun"); 231 232 expect(hostMarker).toContain(`executor=${output.run.executor}`); 232 233 } finally { 234 + await rm(tempDirectory, { recursive: true, force: true }); 235 + } 236 + }); 237 + 238 + it("enforces maxRunDepth recursion guard on nested run submissions", async () => { 239 + const tempDirectory = await mkdtemp(join(tmpdir(), "mill-run-depth-")); 240 + const homeDirectory = join(tempDirectory, "home"); 241 + const programPath = join(tempDirectory, "program.ts"); 242 + const previousDepth = process.env.MILL_RUN_DEPTH; 243 + 244 + await writeFile(programPath, "return 'ok';\n", "utf-8"); 245 + 246 + try { 247 + process.env.MILL_RUN_DEPTH = "1"; 248 + 249 + await expect( 250 + submitRun({ 251 + defaults: makeConfig(), 252 + programPath, 253 + cwd: tempDirectory, 254 + homeDirectory, 255 + pathExists: async () => false, 256 + launchWorker: async () => { 257 + throw new Error("launchWorker should not be called when depth guard blocks run"); 258 + }, 259 + }), 260 + ).rejects.toThrow("maxRunDepth=1"); 261 + 262 + let launchCalled = false; 263 + 264 + const submitted = await submitRun({ 265 + defaults: { 266 + ...makeConfig(), 267 + maxRunDepth: 2, 268 + }, 269 + programPath, 270 + cwd: tempDirectory, 271 + homeDirectory, 272 + pathExists: async () => false, 273 + launchWorker: async () => { 274 + launchCalled = true; 275 + }, 276 + }); 277 + 278 + expect(submitted.status).toBe("pending"); 279 + expect(launchCalled).toBe(true); 280 + } finally { 281 + if (previousDepth === undefined) { 282 + delete process.env.MILL_RUN_DEPTH; 283 + } else { 284 + process.env.MILL_RUN_DEPTH = previousDepth; 285 + } 286 + 233 287 await rm(tempDirectory, { recursive: true, force: true }); 234 288 } 235 289 });
+54
packages/core/src/public/run.api.ts
··· 89 89 export interface RunWorkerInput extends BaseRunInput { 90 90 readonly runId: string; 91 91 readonly programPath: string; 92 + readonly runDepth?: number; 92 93 } 93 94 94 95 export interface LaunchWorkerInput { ··· 98 99 readonly driverName: string; 99 100 readonly executorName: string; 100 101 readonly cwd: string; 102 + readonly runDepth: number; 101 103 } 102 104 103 105 interface EngineContext { ··· 107 109 readonly selectedExecutorRuntime: ExecutorRuntime; 108 110 readonly selectedExtensions: ReadonlyArray<ExtensionRegistration>; 109 111 readonly runsDirectory: string; 112 + readonly maxRunDepth: number; 110 113 } 111 114 112 115 const DEFAULT_SYNC_WAIT_TIMEOUT_SECONDS = 60 * 60 * 24 * 365; 113 116 const WORKER_PID_FILENAME = "worker.pid"; 114 117 const CANCEL_LOG_PATH = "logs/cancel.log"; 115 118 const PROCESS_EXIT_GRACE_MILLIS = 400; 119 + const RUN_DEPTH_ENV = "MILL_RUN_DEPTH"; 120 + const DEFAULT_MAX_RUN_DEPTH = 1; 116 121 117 122 const normalizePath = (path: string): string => { 118 123 if (path.length <= 1) { ··· 350 355 return joinPath(cwd, ".mill/runs"); 351 356 }; 352 357 358 + const parseInteger = (value: string | undefined): number | undefined => { 359 + if (value === undefined) { 360 + return undefined; 361 + } 362 + 363 + const parsed = Number.parseInt(value, 10); 364 + 365 + if (!Number.isInteger(parsed)) { 366 + return undefined; 367 + } 368 + 369 + return parsed; 370 + }; 371 + 372 + const resolveCurrentRunDepth = (): number => { 373 + const parsed = parseInteger(process.env[RUN_DEPTH_ENV]); 374 + 375 + if (parsed === undefined || parsed < 0) { 376 + return 0; 377 + } 378 + 379 + return parsed; 380 + }; 381 + 382 + const resolveMaxRunDepth = (configured: number | undefined): number => { 383 + if (configured === undefined || !Number.isInteger(configured) || configured <= 0) { 384 + return DEFAULT_MAX_RUN_DEPTH; 385 + } 386 + 387 + return configured; 388 + }; 389 + 353 390 const runWithBunContext = <A, E>(effect: Effect.Effect<A, E, BunContext.BunContext>): Promise<A> => 354 391 Runtime.runPromise(runtime)(Effect.provide(effect, BunContext.layer)); 355 392 ··· 401 438 selectedExecutorRuntime: selectedExecutor.runtime, 402 439 selectedExtensions: resolvedConfig.config.extensions, 403 440 runsDirectory, 441 + maxRunDepth: resolveMaxRunDepth(resolvedConfig.config.maxRunDepth), 404 442 engine: makeMillEngine({ 405 443 runsDirectory, 406 444 driverName: selectedDriver.name, ··· 472 510 const engineContext = await makeEngineForConfig(input); 473 511 const runId = decodeRunIdSync(`run_${crypto.randomUUID()}`); 474 512 513 + const currentRunDepth = resolveCurrentRunDepth(); 514 + const nextRunDepth = currentRunDepth + 1; 515 + 516 + if (nextRunDepth > engineContext.maxRunDepth) { 517 + return Promise.reject( 518 + new Error( 519 + `Run depth ${nextRunDepth} exceeds configured maxRunDepth=${engineContext.maxRunDepth}.`, 520 + ), 521 + ); 522 + } 523 + 475 524 const submittedRun = await runWithBunContext( 476 525 engineContext.engine.submit({ 477 526 runId, ··· 491 540 driverName: engineContext.selectedDriverName, 492 541 executorName: engineContext.selectedExecutorName, 493 542 cwd, 543 + runDepth: nextRunDepth, 494 544 }); 495 545 496 546 return submittedRun; ··· 535 585 const engineContext = await makeEngineForConfig(input); 536 586 const runDirectory = runDirectoryFor(engineContext.runsDirectory, input.runId); 537 587 const workerPidPath = workerPidPathFor(runDirectory); 588 + const runDepth = input.runDepth ?? resolveCurrentRunDepth(); 538 589 539 590 fs.mkdirSync(runDirectory, { recursive: true }); 540 591 fs.writeFileSync(workerPidPath, `${process.pid}\n`, "utf-8"); ··· 559 610 programSource, 560 611 executorName: engineContext.selectedExecutorName, 561 612 extensions: engineContext.selectedExtensions, 613 + env: { 614 + [RUN_DEPTH_ENV]: String(runDepth), 615 + }, 562 616 spawn, 563 617 onIo: ({ stream, line }) => 564 618 Effect.flatMap(
+2
packages/core/src/public/types.ts
··· 118 118 readonly defaultDriver: string; 119 119 readonly defaultExecutor: string; 120 120 readonly defaultModel: string; 121 + readonly maxRunDepth?: number; 121 122 readonly drivers: Readonly<Record<string, DriverRegistration>>; 122 123 readonly executors: Readonly<Record<string, ExecutorRegistration>>; 123 124 readonly extensions: ReadonlyArray<ExtensionRegistration>; ··· 168 169 readonly defaultDriver?: string; 169 170 readonly defaultExecutor?: string; 170 171 readonly defaultModel?: string; 172 + readonly maxRunDepth?: number; 171 173 readonly drivers?: Readonly<Record<string, DriverRegistration>>; 172 174 readonly executors?: Readonly<Record<string, ExecutorRegistration>>; 173 175 readonly extensions?: ReadonlyArray<ExtensionRegistration>;
+6 -1
packages/core/src/runtime/program-host.effect.ts
··· 23 23 readonly programSource: string; 24 24 readonly executorName: string; 25 25 readonly extensions: ReadonlyArray<ExtensionRegistration>; 26 + readonly env?: Readonly<Record<string, string>>; 26 27 readonly spawn: (input: SpawnOptions) => Effect.Effect<SpawnResult, unknown>; 27 28 readonly onIo?: (input: { 28 29 readonly stream: "stdout" | "stderr"; ··· 289 290 }), 290 291 ); 291 292 292 - const command = Command.make(process.execPath, "run", hostProgramPath).pipe( 293 + const baseCommand = Command.make(process.execPath, "run", hostProgramPath).pipe( 293 294 Command.workingDirectory(input.workingDirectory), 294 295 Command.stdin("pipe"), 295 296 Command.stdout("pipe"), 296 297 Command.stderr("pipe"), 297 298 ); 299 + const command = 300 + input.env === undefined || Object.keys(input.env).length === 0 301 + ? baseCommand 302 + : Command.env(baseCommand, input.env); 298 303 299 304 yield* Effect.logDebug("mill.program-host:start", { 300 305 runId: input.runId,
+2 -2
packages/pi-mill/README.md
··· 20 20 21 21 ## Prerequisites 22 22 23 - 1. `mill` must be on your `PATH` (or set a custom command in config). 24 - 2. A `mill.config.ts` with at least one driver/executor configured. 23 + 1. `mill` must be on your `PATH` — see [install instructions](https://github.com/laulauland/mill#install). 24 + 2. A `mill.config.ts` with at least one driver/executor configured (`mill init` to scaffold one). 25 25 26 26 ## How it works 27 27