@markjaquith/agency 2.19.0 → 2.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,7 +10,7 @@ or write.
10
10
  - [Bun](https://bun.sh) 1.0 or newer
11
11
  - Git
12
12
  - [GitHub CLI](https://cli.github.com/) for `agency pr create`
13
- - OpenCode or Claude Code for `agency work`
13
+ - OpenCode, Claude Code, or a configured runner for `agency work`
14
14
 
15
15
  ## Installation
16
16
 
@@ -145,6 +145,41 @@ The configured command applies only to the writable checkout. Supplemental
145
145
  read-only repositories remain detached Git worktrees at their declared refs so
146
146
  they do not acquire writable branches.
147
147
 
148
+ ### Agent Runners
149
+
150
+ OpenCode and Claude Code are built-in runner presets. Select either preset or a
151
+ configured runner with `agency work --runner <name>`. A launch is fresh unless
152
+ `AGENCY_SESSION_ID` is already set; resumed launches use the runner's
153
+ `resumeCommand` when configured. The built-in presets use `--continue` only for
154
+ resumed launches.
155
+
156
+ Custom runners are direct argv commands, never shell snippets:
157
+
158
+ ```json
159
+ {
160
+ "version": 2,
161
+ "runners": {
162
+ "custom": {
163
+ "command": ["my-agent", "--prompt", "{prompt}"],
164
+ "resumeCommand": ["my-agent", "resume", "{sessionId}", "{prompt}"],
165
+ "environment": { "MY_AGENT_TARGET": "{target}" }
166
+ }
167
+ }
168
+ }
169
+ ```
170
+
171
+ Available placeholders are `{prompt}`, `{workbase}`, `{target}`, `{task}`,
172
+ `{phase}`, `{claimant}`, `{sessionId}`, and `{claimRevision}`. Task and phase
173
+ placeholders are empty when they do not apply. If `resumeCommand` is omitted,
174
+ the fresh command is also used for resumed sessions.
175
+
176
+ Every runner receives the same `AGENCY_RUNNER`, `AGENCY_CLAIMANT`,
177
+ `AGENCY_SESSION_ID`, `AGENCY_CLAIM_REVISION`, `AGENCY_WORKBASE`, `AGENCY_TARGET`,
178
+ `AGENCY_TASK_ID`, `AGENCY_PHASE_ID`, and `AGENCY_PROMPT` environment. Configured
179
+ environment is added without overriding these normalized values.
180
+ `--print-command` prints the exact cwd and argv plus non-secret environment keys
181
+ without launching the runner.
182
+
148
183
  ### Custom Chooser Command
149
184
 
150
185
  Interactive selection uses a native numbered chooser by default. To use an
@@ -500,9 +535,9 @@ replaced with a revision-guarded claim.
500
535
 
501
536
  `agency work` claims an execution unit before launching its agent. Set
502
537
  `AGENCY_CLAIMANT`, `AGENCY_RUNNER`, or `AGENCY_SESSION_ID` to supply orchestrator
503
- identities; otherwise Agency derives them from the user, selected agent, and
504
- process. The launched agent receives `AGENCY_SESSION_ID` and
505
- `AGENCY_CLAIM_REVISION` for a later release or finish operation.
538
+ identities; otherwise Agency derives them from the user and process. The selected
539
+ runner name is recorded on the claim. The launched agent receives the normalized
540
+ runner environment documented above for a later release or finish operation.
506
541
 
507
542
  ### Archive
508
543
 
@@ -520,7 +555,7 @@ before moving files, refuses dirty worktrees, and preserves branches.
520
555
  ### Work and Pull Requests
521
556
 
522
557
  ```text
523
- agency work [<directory> | --epic <epic-id>] [--opencode | --claude]
558
+ agency work [<directory> | --epic <epic-id>] [--runner <name>] [--print-command]
524
559
  agency work prepare [target] [--dry-run] [--json]
525
560
  agency pr create <task-id> [phase-id] [--draft] [--json]
526
561
  ```
@@ -531,6 +566,10 @@ workbase, Agency first presents the registered workbases, then the selected
531
566
  workbase's hierarchy. If `fzf` is not installed, Agency prints the available
532
567
  choices and asks for an explicit directory.
533
568
 
569
+ OpenCode is the default runner, with automatic Claude fallback when neither is
570
+ explicitly selected. `--opencode` and `--claude` remain aliases for requiring
571
+ their built-in presets.
572
+
534
573
  `agency work prepare` resolves an execution unit and creates or reuses its
535
574
  writable and reference worktrees without launching an agent or changing status.
536
575
  Its JSON result includes document and checkout paths, resolved commits, actions,
package/cli.ts CHANGED
@@ -419,6 +419,8 @@ const commands: Record<string, Command> = {
419
419
  verbose: options.verbose,
420
420
  opencode: options.opencode,
421
421
  claude: options.claude,
422
+ runner: options.runner,
423
+ printCommand: options["print-command"],
422
424
  force: options.force,
423
425
  inputAllowed: options.inputAllowed,
424
426
  cwd: options.cwd,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "2.19.0",
3
+ "version": "2.20.0",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -279,7 +279,10 @@ agency work --epic <epic-id>
279
279
  agency work --task <task-id> [--phase <phase-id>] --workbase <selector>
280
280
  ```
281
281
 
282
- Use `--opencode` or `--claude` to require a specific agent. This command fetches
282
+ Use `--runner <name>` to select a configured runner or the built-in `opencode`
283
+ and `claude` presets. `--opencode` and `--claude` remain shorthand. Use
284
+ `--print-command` to inspect the resolved cwd, argv, and non-secret environment
285
+ without launching. This command fetches
283
286
  repositories for execution targets, creates or reuses their worktrees, and
284
287
  replaces the current process with the selected agent. With no directory it opens
285
288
  an `fzf` picker containing the workbase hierarchy. Pass `.`, or another
@@ -321,6 +321,22 @@ describe("strict CLI parsing", () => {
321
321
  )
322
322
  })
323
323
 
324
+ test("accepts runner selection and command inspection for work", () => {
325
+ expect(
326
+ parseCli(["work", "example", "--runner", "custom", "--print-command"]),
327
+ ).toMatchObject({
328
+ commandName: "work",
329
+ args: ["example"],
330
+ values: { runner: "custom", "print-command": true },
331
+ })
332
+ expect(() =>
333
+ parseCli(["work", "example", "--runner", "custom", "--claude"]),
334
+ ).toThrow("cannot be combined")
335
+ expect(() => parseCli(["work", "prepare", "--print-command"])).toThrow(
336
+ "cannot be combined",
337
+ )
338
+ })
339
+
324
340
  test("accepts repeatable graph filters and rejects output conflicts", () => {
325
341
  expect(
326
342
  parseCli([
package/src/cli-parser.ts CHANGED
@@ -439,19 +439,21 @@ const commands = {
439
439
  },
440
440
  work: {
441
441
  usage:
442
- "agency work [<directory-or-task-id> | --epic <epic-id>] | agency work prepare [target] [--dry-run] [--json]",
442
+ "agency work [<directory-or-task-id> | --epic <epic-id>] [--runner <name>] | agency work prepare [target] [--dry-run] [--json]",
443
443
  options: {
444
444
  ...commonOptions,
445
445
  ...entitySelectorOptions,
446
446
  json: { type: "boolean" },
447
447
  "dry-run": { type: "boolean" },
448
+ runner: { type: "string" },
449
+ "print-command": { type: "boolean" },
448
450
  opencode: { type: "boolean" },
449
451
  claude: { type: "boolean" },
450
452
  force: { type: "boolean" },
451
453
  },
452
454
  command: {
453
455
  usage:
454
- "agency work [<directory-or-task-id> | --epic <epic-id>] | agency work prepare [target] [--dry-run] [--json]",
456
+ "agency work [<directory-or-task-id> | --epic <epic-id>] [--runner <name>] | agency work prepare [target] [--dry-run] [--json]",
455
457
  minArgs: 0,
456
458
  maxArgs: 2,
457
459
  options: [
@@ -460,12 +462,16 @@ const commands = {
460
462
  "epic",
461
463
  "task",
462
464
  "phase",
465
+ "runner",
466
+ "print-command",
463
467
  "opencode",
464
468
  "claude",
465
469
  "force",
466
470
  ],
467
471
  conflicts: [
468
472
  ["opencode", "claude"],
473
+ ["runner", "opencode"],
474
+ ["runner", "claude"],
469
475
  ["epic", "$positional"],
470
476
  ],
471
477
  },
@@ -998,6 +1004,8 @@ export function parseCli(args: readonly string[]): ParsedCli {
998
1004
  (parsed.values.epic ||
999
1005
  parsed.values.opencode ||
1000
1006
  parsed.values.claude ||
1007
+ parsed.values.runner ||
1008
+ parsed.values["print-command"] ||
1001
1009
  parsed.values.force))
1002
1010
  ) {
1003
1011
  throw usageError(
@@ -47,8 +47,16 @@ const multiPhaseWorkspace: ExecutionWorkspace = {
47
47
  interface HarnessOptions {
48
48
  readonly workspace?: ExecutionWorkspace
49
49
  readonly materializeError?: Error
50
- readonly available?: Partial<Record<"opencode" | "claude", boolean>>
50
+ readonly available?: Readonly<Record<string, boolean>>
51
51
  readonly chooserCommand?: readonly string[]
52
+ readonly runners?: Record<
53
+ string,
54
+ {
55
+ command: readonly [string, ...string[]]
56
+ resumeCommand?: readonly [string, ...string[]]
57
+ environment?: Record<string, string>
58
+ }
59
+ >
52
60
  readonly multiPhaseTasks?: readonly string[]
53
61
  readonly epicRecords?: readonly any[]
54
62
  readonly taskRecords?: readonly any[]
@@ -72,6 +80,7 @@ const createHarness = (options: HarnessOptions = {}) => {
72
80
  args: readonly string[]
73
81
  cwd: string
74
82
  }> = []
83
+ const launchEnvironments: Array<Readonly<Record<string, string>>> = []
75
84
  const materializeOptions: Array<
76
85
  Parameters<WorktreeService["materialize"]>[3]
77
86
  > = []
@@ -105,6 +114,7 @@ const createHarness = (options: HarnessOptions = {}) => {
105
114
  config: {
106
115
  version: 2 as const,
107
116
  chooserCommand: options.chooserCommand,
117
+ runners: options.runners,
108
118
  },
109
119
  }),
110
120
  }
@@ -204,7 +214,7 @@ const createHarness = (options: HarnessOptions = {}) => {
204
214
  isDirectory: (path: string) =>
205
215
  Effect.succeed(options.existingDirectories?.includes(path) ?? true),
206
216
  runCommand: (args: readonly string[]) => {
207
- const cli = args[1] as "opencode" | "claude"
217
+ const cli = args[1]!
208
218
  events.push(`probe:${cli}`)
209
219
  probes.push(cli)
210
220
  return Effect.succeed({
@@ -214,9 +224,15 @@ const createHarness = (options: HarnessOptions = {}) => {
214
224
  })
215
225
  },
216
226
  }
217
- const launch = (cli: string, args: readonly string[], cwd: string) => {
227
+ const launch = (
228
+ cli: string,
229
+ args: readonly string[],
230
+ cwd: string,
231
+ environment: Readonly<Record<string, string>>,
232
+ ) => {
218
233
  events.push(`launch:${cli}`)
219
234
  launches.push({ cli, args, cwd })
235
+ launchEnvironments.push(environment)
220
236
  }
221
237
  const defaultPick: PickWorkTarget = () => Effect.succeed(null)
222
238
  const defaultPickWorkbase: PickWorkbase = () => Effect.succeed(null)
@@ -257,6 +273,7 @@ const createHarness = (options: HarnessOptions = {}) => {
257
273
  events,
258
274
  probes,
259
275
  launches,
276
+ launchEnvironments,
260
277
  materializeOptions,
261
278
  statusUpdates,
262
279
  shownTasks,
@@ -353,7 +370,6 @@ describe("work command", () => {
353
370
  cli: "opencode",
354
371
  args: [
355
372
  "opencode",
356
- "--continue",
357
373
  "--prompt",
358
374
  "Work on the epic. Read /workbase/epics/delivery/EPIC.md.",
359
375
  ],
@@ -403,7 +419,6 @@ describe("work command", () => {
403
419
  cli: "opencode",
404
420
  args: [
405
421
  "opencode",
406
- "--continue",
407
422
  "--prompt",
408
423
  "Work on the task. Read /workbase/tasks/delivery/TASK.md.",
409
424
  ],
@@ -429,7 +444,6 @@ describe("work command", () => {
429
444
  cli: "opencode",
430
445
  args: [
431
446
  "opencode",
432
- "--continue",
433
447
  "--prompt",
434
448
  "Start the task. Read /workbase/tasks/example/TASK.md and /workbase/tasks/example/phases/implementation/PHASE.md.",
435
449
  ],
@@ -596,7 +610,7 @@ describe("work command", () => {
596
610
 
597
611
  await expect(
598
612
  harness.run({ taskId: "example", opencode: true, claude: true }),
599
- ).rejects.toThrow("Cannot use both --opencode and --claude")
613
+ ).rejects.toThrow("Cannot combine --runner, --opencode, and --claude")
600
614
  expect(harness.events).toEqual([])
601
615
  })
602
616
 
@@ -624,7 +638,6 @@ describe("work command", () => {
624
638
  cli: "opencode",
625
639
  args: [
626
640
  "opencode",
627
- "--continue",
628
641
  "--prompt",
629
642
  "Start the task. Read /workbase/tasks/example/TASK.md.",
630
643
  ],
@@ -638,14 +651,18 @@ describe("work command", () => {
638
651
  ])
639
652
  })
640
653
 
641
- test("continues OpenCode with explicit task and phase context", async () => {
654
+ test("resumes OpenCode deterministically when a session identity exists", async () => {
642
655
  const harness = createHarness({ workspace: multiPhaseWorkspace })
643
-
644
- await harness.run({
645
- taskId: "example",
646
- phaseId: "implementation",
647
- opencode: true,
648
- })
656
+ process.env.AGENCY_SESSION_ID = "existing-session"
657
+ try {
658
+ await harness.run({
659
+ taskId: "example",
660
+ phaseId: "implementation",
661
+ opencode: true,
662
+ })
663
+ } finally {
664
+ delete process.env.AGENCY_SESSION_ID
665
+ }
649
666
 
650
667
  expect(harness.launches[0]).toEqual({
651
668
  cli: "opencode",
@@ -659,6 +676,78 @@ describe("work command", () => {
659
676
  })
660
677
  })
661
678
 
679
+ test("expands a named runner with shared context and claim identity", async () => {
680
+ const harness = createHarness({
681
+ available: { codex: true },
682
+ runners: {
683
+ custom: {
684
+ command: ["codex", "--task", "{task}", "{prompt}"],
685
+ environment: {
686
+ CUSTOM_TARGET: "{target}",
687
+ AGENCY_TARGET: "cannot-override",
688
+ },
689
+ },
690
+ },
691
+ })
692
+
693
+ await harness.run({ taskId: "example", runner: "custom" })
694
+
695
+ expect(harness.probes).toEqual(["codex"])
696
+ expect(harness.launches[0]).toEqual({
697
+ cli: "codex",
698
+ args: [
699
+ "codex",
700
+ "--task",
701
+ "example",
702
+ "Start the task. Read /workbase/tasks/example/TASK.md.",
703
+ ],
704
+ cwd: singlePhaseWorkspace.writablePath,
705
+ })
706
+ expect(harness.launchEnvironments[0]).toMatchObject({
707
+ AGENCY_RUNNER: "custom",
708
+ AGENCY_CLAIMANT: process.env.USER ?? "agency",
709
+ AGENCY_WORKBASE: "/workbase",
710
+ AGENCY_TARGET: "execution-unit:task/example",
711
+ AGENCY_TASK_ID: "example",
712
+ AGENCY_PHASE_ID: "",
713
+ AGENCY_CLAIM_REVISION: "1".repeat(64),
714
+ CUSTOM_TARGET: "execution-unit:task/example",
715
+ })
716
+ })
717
+
718
+ test("prints the exact command contract without launching and omits secrets", async () => {
719
+ const harness = createHarness({
720
+ available: { agent: true },
721
+ runners: {
722
+ custom: {
723
+ command: ["agent", "{prompt}"],
724
+ environment: {
725
+ VISIBLE: "{task}",
726
+ API_TOKEN: "do-not-print",
727
+ },
728
+ },
729
+ },
730
+ })
731
+
732
+ const output = await captureLogs(() =>
733
+ harness.run({
734
+ taskId: "example",
735
+ runner: "custom",
736
+ printCommand: true,
737
+ }),
738
+ )
739
+ const printed = JSON.parse(output.join("\n"))
740
+
741
+ expect(harness.launches).toEqual([])
742
+ expect(printed.cwd).toBe(singlePhaseWorkspace.writablePath)
743
+ expect(printed.argv).toEqual([
744
+ "agent",
745
+ "Start the task. Read /workbase/tasks/example/TASK.md.",
746
+ ])
747
+ expect(printed.environment.VISIBLE).toBe("example")
748
+ expect(printed.environment.API_TOKEN).toBeUndefined()
749
+ })
750
+
662
751
  test("automatically falls back to Claude", async () => {
663
752
  const harness = createHarness({ available: { opencode: false } })
664
753
 
@@ -729,7 +818,7 @@ describe("work command", () => {
729
818
  verboseHarness.run({ taskId: "example", verbose: true }),
730
819
  )
731
820
  expect(verboseLogs).toEqual([
732
- "Launching command: opencode --continue --prompt 'Start the task. Read /workbase/tasks/example/TASK.md.' (cwd: /workbase/tasks/example/code/agency)",
821
+ "Launching command: opencode --prompt 'Start the task. Read /workbase/tasks/example/TASK.md.' (cwd: /workbase/tasks/example/code/agency)",
733
822
  ])
734
823
  expect(verboseHarness.materializeOptions[0]?.verbose).toBe(true)
735
824
 
@@ -23,6 +23,11 @@ import {
23
23
  resolveWorkbase,
24
24
  type PickWorkbase,
25
25
  } from "../workbase/workbase-choice"
26
+ import {
27
+ printableEnvironment,
28
+ resolveRunnerCommand,
29
+ runnerEnvironment,
30
+ } from "../workbase/runner-command"
26
31
 
27
32
  interface WorkOptions extends BaseCommandOptions {
28
33
  readonly directory?: string
@@ -31,10 +36,17 @@ interface WorkOptions extends BaseCommandOptions {
31
36
  readonly epicId?: string
32
37
  readonly opencode?: boolean
33
38
  readonly claude?: boolean
39
+ readonly runner?: string
40
+ readonly printCommand?: boolean
34
41
  readonly force?: boolean
35
42
  }
36
43
 
37
- type LaunchAgent = (cli: string, args: readonly string[], cwd: string) => void
44
+ type LaunchAgent = (
45
+ cli: string,
46
+ args: readonly string[],
47
+ cwd: string,
48
+ environment: Readonly<Record<string, string>>,
49
+ ) => void
38
50
 
39
51
  const formatCommand = (args: readonly string[]) =>
40
52
  args
@@ -68,13 +80,15 @@ export const work = (
68
80
  pickBase: PickWorkbase = pickWorkbase,
69
81
  ) =>
70
82
  Effect.gen(function* () {
71
- if (options.opencode && options.claude) {
83
+ if (
84
+ (options.opencode && options.claude) ||
85
+ (options.runner && (options.opencode || options.claude))
86
+ ) {
72
87
  return yield* Effect.fail(
73
- new Error("Cannot use both --opencode and --claude"),
88
+ new Error("Cannot combine --runner, --opencode, and --claude"),
74
89
  )
75
90
  }
76
- const previousSessionId = process.env.AGENCY_SESSION_ID
77
- const previousClaimRevision = process.env.AGENCY_CLAIM_REVISION
91
+ const previousEnvironment = { ...process.env }
78
92
  if (
79
93
  options.epicId &&
80
94
  (options.directory || options.taskId || options.phaseId)
@@ -104,6 +118,7 @@ export const work = (
104
118
  const inputAllowed = options.inputAllowed ?? true
105
119
  const root = yield* resolveWorkbase(startPath, pickBase, inputAllowed)
106
120
  if (!root) return
121
+ const { config } = yield* workbase.loadConfig(root)
107
122
 
108
123
  let target: WorkTarget | null = null
109
124
  if (options.epicId) {
@@ -199,7 +214,6 @@ export const work = (
199
214
  new Error("No ready work targets found in this workbase"),
200
215
  )
201
216
  }
202
- const { config } = yield* workbase.loadConfig(root)
203
217
  target = yield* pick(choices, config.chooserCommand)
204
218
  if (!target) return
205
219
  }
@@ -233,13 +247,49 @@ export const work = (
233
247
  launchPath = workspace.writablePath
234
248
  }
235
249
 
236
- const requested = options.claude ? "claude" : "opencode"
237
- let cli = requested
250
+ const explicitlyRequested = Boolean(
251
+ options.runner ||
252
+ options.opencode ||
253
+ options.claude ||
254
+ process.env.AGENCY_RUNNER,
255
+ )
256
+ let runner =
257
+ options.runner ??
258
+ process.env.AGENCY_RUNNER ??
259
+ (options.claude ? "claude" : "opencode")
260
+ const claimant = process.env.AGENCY_CLAIMANT ?? process.env.USER ?? "agency"
261
+ const sessionId =
262
+ process.env.AGENCY_SESSION_ID ?? `${process.pid}-${Date.now()}`
263
+ const resume = process.env.AGENCY_SESSION_ID !== undefined
264
+ let claimRevision = ""
265
+ let variables = {
266
+ prompt,
267
+ workbase: root,
268
+ target: targetNodeId(target),
269
+ task: target.kind === "epic" ? "" : target.taskId,
270
+ phase: target.kind === "phase" ? target.phaseId : "",
271
+ claimant,
272
+ sessionId,
273
+ claimRevision,
274
+ }
275
+ let resolved = resolveRunnerCommand(
276
+ runner,
277
+ config.runners,
278
+ variables,
279
+ resume,
280
+ )
281
+ let cli = resolved.argv[0]!
238
282
  let available = yield* fs.runCommand(["which", cli], {
239
283
  captureOutput: true,
240
284
  })
241
- if (available.exitCode !== 0 && !options.opencode && !options.claude) {
242
- cli = "claude"
285
+ if (
286
+ available.exitCode !== 0 &&
287
+ !explicitlyRequested &&
288
+ runner === "opencode"
289
+ ) {
290
+ runner = "claude"
291
+ resolved = resolveRunnerCommand(runner, config.runners, variables, resume)
292
+ cli = resolved.argv[0]!
243
293
  available = yield* fs.runCommand(["which", cli], { captureOutput: true })
244
294
  }
245
295
  if (available.exitCode !== 0) {
@@ -251,36 +301,55 @@ export const work = (
251
301
  ) {
252
302
  const phaseId = target.kind === "phase" ? target.phaseId : undefined
253
303
  const current = yield* claims.inspect(target.taskId, phaseId, root)
254
- const sessionId =
255
- process.env.AGENCY_SESSION_ID ?? `${process.pid}-${Date.now()}`
256
304
  const acquired = yield* claims.claim(
257
305
  {
258
306
  taskId: target.taskId,
259
307
  ...(phaseId ? { phaseId } : {}),
260
- claimant: process.env.AGENCY_CLAIMANT ?? process.env.USER ?? "agency",
261
- runner: process.env.AGENCY_RUNNER ?? cli,
308
+ claimant,
309
+ runner,
262
310
  sessionId,
263
311
  revision: current.revision,
264
312
  },
265
313
  root,
266
314
  )
267
- process.env.AGENCY_SESSION_ID = sessionId
268
- process.env.AGENCY_CLAIM_REVISION = acquired.revision
315
+ claimRevision = acquired.revision
269
316
  }
270
317
 
271
- const args =
272
- cli === "opencode" ? ["--continue", "--prompt", prompt] : [prompt]
318
+ variables = { ...variables, claimRevision }
319
+ resolved = resolveRunnerCommand(runner, config.runners, variables, resume)
320
+ cli = resolved.argv[0]!
321
+ const environment = {
322
+ ...resolved.environment,
323
+ ...runnerEnvironment(runner, variables),
324
+ }
325
+ if (options.printCommand) {
326
+ log(
327
+ JSON.stringify(
328
+ {
329
+ cwd: launchPath,
330
+ argv: resolved.argv,
331
+ environment: printableEnvironment(environment),
332
+ },
333
+ null,
334
+ 2,
335
+ ),
336
+ )
337
+ return
338
+ }
339
+ for (const [key, value] of Object.entries(environment)) {
340
+ process.env[key] = value
341
+ }
273
342
  verboseLog(
274
- `Launching command: ${formatCommand([cli, ...args])} (cwd: ${launchPath})`,
343
+ `Launching command: ${formatCommand(resolved.argv)} (cwd: ${launchPath})`,
275
344
  )
276
345
  try {
277
- launch(cli, [cli, ...args], launchPath)
346
+ launch(cli, resolved.argv, launchPath, environment)
278
347
  } finally {
279
- if (previousSessionId === undefined) delete process.env.AGENCY_SESSION_ID
280
- else process.env.AGENCY_SESSION_ID = previousSessionId
281
- if (previousClaimRevision === undefined)
282
- delete process.env.AGENCY_CLAIM_REVISION
283
- else process.env.AGENCY_CLAIM_REVISION = previousClaimRevision
348
+ for (const key of Object.keys(environment)) {
349
+ const previous = previousEnvironment[key]
350
+ if (previous === undefined) delete process.env[key]
351
+ else process.env[key] = previous
352
+ }
284
353
  }
285
354
  })
286
355
 
@@ -344,7 +413,7 @@ export const workPrepare = (options: WorkOptions = {}) =>
344
413
  })
345
414
 
346
415
  export const help = `
347
- Usage: agency work [<directory-or-task-id> | --epic <epic-id>]
416
+ Usage: agency work [<directory-or-task-id> | --epic <epic-id>] [--runner <name>]
348
417
  agency work prepare [target] [--dry-run] [--json]
349
418
 
350
419
  Launch an agent for an epic, task, or phase. With no directory, select one
@@ -362,8 +431,10 @@ Options:
362
431
  --phase <id> Work on a phase selected with --task
363
432
  --workbase <target> Select a workbase by ID, name, or path
364
433
  --cwd <path> Resolve context from a specific directory
365
- --opencode Require OpenCode
366
- --claude Require Claude Code
434
+ --runner <name> Select a configured runner or built-in preset
435
+ --print-command Print cwd, argv, and non-secret environment without launch
436
+ --opencode Require the OpenCode preset
437
+ --claude Require the Claude Code preset
367
438
  --force Override readiness and terminal-state guards
368
439
  --no-input Never open an interactive selector
369
440
 
@@ -235,6 +235,25 @@ describe("WorkbaseService", () => {
235
235
  ).rejects.toThrow("{worktree}")
236
236
  })
237
237
 
238
+ test("rejects an unknown runner command placeholder", async () => {
239
+ await write(
240
+ root,
241
+ "agency.json",
242
+ JSON.stringify({
243
+ version: 2,
244
+ runners: { custom: { command: ["agent", "{unknown}"] } },
245
+ }),
246
+ )
247
+
248
+ await expect(
249
+ runTestEffect(
250
+ WorkbaseService.pipe(
251
+ Effect.flatMap((service) => service.discover(root)),
252
+ ),
253
+ ),
254
+ ).rejects.toThrow("{unknown}")
255
+ })
256
+
238
257
  test("validates a workbase with an epic and multi-phase task", async () => {
239
258
  await write(root, "agency.json", '{"version":2}\n')
240
259
  await mkdir(join(root, "repos/agency"), { recursive: true })
@@ -21,6 +21,7 @@ import {
21
21
  type WorkbaseRegistration,
22
22
  } from "../workbase/schemas"
23
23
  import { validateWorktreeCreateCommand } from "../workbase/worktree-command"
24
+ import { validateRunners } from "../workbase/runner-command"
24
25
 
25
26
  class WorkbaseNotFoundError extends Data.TaggedError("WorkbaseNotFoundError")<{
26
27
  readonly message: string
@@ -277,6 +278,17 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
277
278
  })
278
279
  }
279
280
  }
281
+ try {
282
+ validateRunners(decoded.value.runners)
283
+ } catch (cause) {
284
+ return yield* new WorkbaseConfigError({
285
+ path: configPath,
286
+ message:
287
+ cause instanceof Error
288
+ ? cause.message
289
+ : "Invalid runner configuration",
290
+ })
291
+ }
280
292
  return current
281
293
  }
282
294
  }
@@ -0,0 +1,79 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import {
3
+ printableEnvironment,
4
+ resolveRunnerCommand,
5
+ runnerEnvironment,
6
+ validateRunners,
7
+ } from "./runner-command"
8
+
9
+ const variables = {
10
+ prompt: "Read the task.",
11
+ workbase: "/workbase",
12
+ target: "execution-unit:phase/task/build",
13
+ task: "task",
14
+ phase: "build",
15
+ claimant: "orchestrator",
16
+ sessionId: "session-1",
17
+ claimRevision: "revision-1",
18
+ }
19
+
20
+ describe("runner commands", () => {
21
+ test("uses deterministic fresh and resume commands for built-in presets", () => {
22
+ expect(
23
+ resolveRunnerCommand("opencode", undefined, variables, false).argv,
24
+ ).toEqual(["opencode", "--prompt", "Read the task."])
25
+ expect(
26
+ resolveRunnerCommand("opencode", undefined, variables, true).argv,
27
+ ).toEqual(["opencode", "--continue", "--prompt", "Read the task."])
28
+ expect(
29
+ resolveRunnerCommand("claude", undefined, variables, true).argv,
30
+ ).toEqual(["claude", "--continue", "Read the task."])
31
+ })
32
+
33
+ test("expands configured argv and environment without a shell", () => {
34
+ const resolved = resolveRunnerCommand(
35
+ "custom",
36
+ {
37
+ custom: {
38
+ command: ["agent", "--target={target}", "{prompt}"],
39
+ environment: { CUSTOM_SESSION: "{sessionId}" },
40
+ },
41
+ },
42
+ variables,
43
+ false,
44
+ )
45
+
46
+ expect(resolved).toEqual({
47
+ argv: [
48
+ "agent",
49
+ "--target=execution-unit:phase/task/build",
50
+ "Read the task.",
51
+ ],
52
+ environment: { CUSTOM_SESSION: "session-1" },
53
+ })
54
+ })
55
+
56
+ test("rejects unknown placeholders", () => {
57
+ expect(() =>
58
+ validateRunners({ custom: { command: ["agent", "{unknown}"] } }),
59
+ ).toThrow("Unknown runner 'custom' placeholder: {unknown}")
60
+ })
61
+
62
+ test("provides normalized Agency environment and filters secret values", () => {
63
+ const environment = {
64
+ ...runnerEnvironment("custom", variables),
65
+ VISIBLE: "yes",
66
+ ACCESS_TOKEN: "secret",
67
+ }
68
+
69
+ expect(environment).toMatchObject({
70
+ AGENCY_RUNNER: "custom",
71
+ AGENCY_CLAIMANT: "orchestrator",
72
+ AGENCY_TARGET: "execution-unit:phase/task/build",
73
+ AGENCY_TASK_ID: "task",
74
+ AGENCY_PHASE_ID: "build",
75
+ })
76
+ expect(printableEnvironment(environment).VISIBLE).toBe("yes")
77
+ expect(printableEnvironment(environment).ACCESS_TOKEN).toBeUndefined()
78
+ })
79
+ })
@@ -0,0 +1,118 @@
1
+ import type { WorkbaseConfig } from "./schemas"
2
+
3
+ export interface RunnerCommandVariables {
4
+ readonly prompt: string
5
+ readonly workbase: string
6
+ readonly target: string
7
+ readonly task: string
8
+ readonly phase: string
9
+ readonly claimant: string
10
+ readonly sessionId: string
11
+ readonly claimRevision: string
12
+ }
13
+
14
+ interface RunnerDefinition {
15
+ readonly command: readonly string[]
16
+ readonly resumeCommand?: readonly string[]
17
+ readonly environment?: Readonly<Record<string, string>>
18
+ }
19
+
20
+ const PLACEHOLDERS = new Set<keyof RunnerCommandVariables>([
21
+ "prompt",
22
+ "workbase",
23
+ "target",
24
+ "task",
25
+ "phase",
26
+ "claimant",
27
+ "sessionId",
28
+ "claimRevision",
29
+ ])
30
+
31
+ const BUILTIN_RUNNERS: Readonly<Record<string, RunnerDefinition>> = {
32
+ opencode: {
33
+ command: ["opencode", "--prompt", "{prompt}"],
34
+ resumeCommand: ["opencode", "--continue", "--prompt", "{prompt}"],
35
+ },
36
+ claude: {
37
+ command: ["claude", "{prompt}"],
38
+ resumeCommand: ["claude", "--continue", "{prompt}"],
39
+ },
40
+ }
41
+
42
+ const validateTemplate = (runner: string, value: string) => {
43
+ for (const match of value.matchAll(/\{([^{}]+)\}/g)) {
44
+ const placeholder = match[1]!
45
+ if (!PLACEHOLDERS.has(placeholder as keyof RunnerCommandVariables)) {
46
+ throw new Error(
47
+ `Unknown runner '${runner}' placeholder: {${placeholder}}`,
48
+ )
49
+ }
50
+ }
51
+ }
52
+
53
+ export const validateRunners = (runners: WorkbaseConfig["runners"]): void => {
54
+ for (const [name, runner] of Object.entries(runners ?? {})) {
55
+ for (const value of [
56
+ ...runner.command,
57
+ ...(runner.resumeCommand ?? []),
58
+ ...Object.values(runner.environment ?? {}),
59
+ ]) {
60
+ validateTemplate(name, value)
61
+ }
62
+ }
63
+ }
64
+
65
+ const expand = (value: string, variables: RunnerCommandVariables) =>
66
+ value.replaceAll(
67
+ /\{([^{}]+)\}/g,
68
+ (match, placeholder: string) =>
69
+ variables[placeholder as keyof RunnerCommandVariables] ?? match,
70
+ )
71
+
72
+ export const resolveRunnerCommand = (
73
+ name: string,
74
+ configured: WorkbaseConfig["runners"],
75
+ variables: RunnerCommandVariables,
76
+ resume: boolean,
77
+ ) => {
78
+ validateRunners(configured)
79
+ const definition = configured?.[name] ?? BUILTIN_RUNNERS[name]
80
+ if (!definition) throw new Error(`Unknown runner: ${name}`)
81
+ const template =
82
+ resume && definition.resumeCommand
83
+ ? definition.resumeCommand
84
+ : definition.command
85
+ const argv = template.map((argument) => expand(argument, variables))
86
+ const environment = Object.fromEntries(
87
+ Object.entries(definition.environment ?? {}).map(([key, value]) => [
88
+ key,
89
+ expand(value, variables),
90
+ ]),
91
+ )
92
+ return { argv, environment }
93
+ }
94
+
95
+ export const runnerEnvironment = (
96
+ runner: string,
97
+ variables: RunnerCommandVariables,
98
+ ): Record<string, string> => ({
99
+ AGENCY_RUNNER: runner,
100
+ AGENCY_CLAIMANT: variables.claimant,
101
+ AGENCY_SESSION_ID: variables.sessionId,
102
+ AGENCY_CLAIM_REVISION: variables.claimRevision,
103
+ AGENCY_WORKBASE: variables.workbase,
104
+ AGENCY_TARGET: variables.target,
105
+ AGENCY_TASK_ID: variables.task,
106
+ AGENCY_PHASE_ID: variables.phase,
107
+ AGENCY_PROMPT: variables.prompt,
108
+ })
109
+
110
+ const SECRET_NAME =
111
+ /(secret|token|password|credential|api[_-]?key|private[_-]?key)/i
112
+
113
+ export const printableEnvironment = (environment: Record<string, string>) =>
114
+ Object.fromEntries(
115
+ Object.entries(environment)
116
+ .filter(([key]) => !SECRET_NAME.test(key))
117
+ .sort(([left], [right]) => left.localeCompare(right)),
118
+ )
@@ -78,6 +78,32 @@ describe("body-of-work descriptions", () => {
78
78
  })
79
79
  })
80
80
 
81
+ describe("runner configuration", () => {
82
+ test("accepts named argv commands with resume commands and environment", () => {
83
+ const config = Schema.decodeUnknownSync(WorkbaseConfig)({
84
+ version: 2,
85
+ runners: {
86
+ custom: {
87
+ command: ["agent", "{prompt}"],
88
+ resumeCommand: ["agent", "resume", "{sessionId}"],
89
+ environment: { CUSTOM_TARGET: "{target}" },
90
+ },
91
+ },
92
+ })
93
+
94
+ expect(config.runners?.custom?.command).toEqual(["agent", "{prompt}"])
95
+ })
96
+
97
+ test("rejects shell strings in place of argv arrays", () => {
98
+ expect(() =>
99
+ Schema.decodeUnknownSync(WorkbaseConfig)({
100
+ version: 2,
101
+ runners: { custom: { command: "agent {prompt}" } },
102
+ }),
103
+ ).toThrow()
104
+ })
105
+ })
106
+
81
107
  describe("work status", () => {
82
108
  const supportedStatuses: Record<WorkStatus, true> = {
83
109
  open: true,
@@ -1,6 +1,9 @@
1
1
  import { Schema } from "@effect/schema"
2
2
 
3
3
  const NonEmptyString = Schema.String.pipe(Schema.minLength(1))
4
+ const EnvironmentName = NonEmptyString.pipe(
5
+ Schema.pattern(/^[A-Za-z_][A-Za-z0-9_]*$/),
6
+ )
4
7
 
5
8
  const Description = Schema.optional(NonEmptyString)
6
9
 
@@ -52,6 +55,18 @@ export const WorkbaseConfig = Schema.Struct({
52
55
  version: Schema.Literal(2),
53
56
  chooserCommand: Schema.optional(Schema.NonEmptyArray(NonEmptyString)),
54
57
  worktreeCreateCommand: Schema.optional(Schema.NonEmptyArray(NonEmptyString)),
58
+ runners: Schema.optional(
59
+ Schema.Record({
60
+ key: EntityId,
61
+ value: Schema.Struct({
62
+ command: Schema.NonEmptyArray(NonEmptyString),
63
+ resumeCommand: Schema.optional(Schema.NonEmptyArray(NonEmptyString)),
64
+ environment: Schema.optional(
65
+ Schema.Record({ key: EnvironmentName, value: Schema.String }),
66
+ ),
67
+ }),
68
+ }),
69
+ ),
55
70
  })
56
71
 
57
72
  export const LegacyWorkbaseRegistry = Schema.Struct({