@markjaquith/agency 2.31.0 → 2.32.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.
@@ -0,0 +1,290 @@
1
+ import {
2
+ createCliRenderer,
3
+ type CliRenderer,
4
+ type CliRendererConfig,
5
+ type InputRenderable,
6
+ } from "@opentui/core"
7
+ import { render, useKeyboard, type JSX } from "@opentui/solid"
8
+ import { createMemo, createSignal, For } from "solid-js"
9
+
10
+ export interface InteractiveChoice {
11
+ readonly key: string
12
+ readonly label: string
13
+ }
14
+
15
+ export const interactiveRendererConfig = {
16
+ screenMode: "split-footer",
17
+ footerHeight: 4,
18
+ externalOutputMode: "capture-stdout",
19
+ consoleMode: "disabled",
20
+ clearOnShutdown: false,
21
+ exitOnCtrlC: false,
22
+ useMouse: false,
23
+ autoFocus: false,
24
+ openConsoleOnError: false,
25
+ } satisfies CliRendererConfig
26
+
27
+ interface PromptProps<T> {
28
+ readonly prompt: string
29
+ readonly onDone: (value: T | null) => void
30
+ }
31
+
32
+ const isCancel = (key: { name: string; ctrl: boolean }) =>
33
+ key.name === "escape" || (key.ctrl && key.name === "c")
34
+
35
+ export const InteractiveTextPrompt = (props: PromptProps<string>) => {
36
+ let input: InputRenderable | undefined
37
+ let value = ""
38
+ useKeyboard((key) => {
39
+ if (isCancel(key)) {
40
+ key.preventDefault()
41
+ key.stopPropagation()
42
+ props.onDone(null)
43
+ return
44
+ }
45
+ if (key.name !== "return") return
46
+ key.preventDefault()
47
+ key.stopPropagation()
48
+ props.onDone(value)
49
+ })
50
+
51
+ return (
52
+ <box flexDirection="column" width="100%" height="100%">
53
+ <text fg="#7aa2f7">{props.prompt}</text>
54
+ <input
55
+ focused
56
+ onInput={(next) => {
57
+ value = next
58
+ }}
59
+ ref={(next) => {
60
+ input = next
61
+ queueMicrotask(() => {
62
+ if (input && !input.isDestroyed) input.focus()
63
+ })
64
+ }}
65
+ />
66
+ <text fg="#6c7086">enter submit | esc cancel</text>
67
+ </box>
68
+ )
69
+ }
70
+
71
+ interface SelectPromptProps extends PromptProps<string> {
72
+ readonly choices: readonly InteractiveChoice[]
73
+ }
74
+
75
+ const isWordBoundary = (value: string, index: number) =>
76
+ index === 0 || /[\s/_.:-]/.test(value[index - 1]!)
77
+
78
+ const fuzzyScore = (value: string, query: string) => {
79
+ const candidate = value.toLowerCase()
80
+ const needle = query.toLowerCase()
81
+ let previous = new Float64Array(candidate.length)
82
+ let current = new Float64Array(candidate.length)
83
+ previous.fill(Number.NEGATIVE_INFINITY)
84
+ let bestScore = Number.NEGATIVE_INFINITY
85
+
86
+ for (let queryIndex = 0; queryIndex < needle.length; queryIndex++) {
87
+ current.fill(Number.NEGATIVE_INFINITY)
88
+ let bestEarlier = Number.NEGATIVE_INFINITY
89
+ bestScore = Number.NEGATIVE_INFINITY
90
+ for (let index = 0; index < candidate.length; index++) {
91
+ if (queryIndex > 0 && index > 0) {
92
+ bestEarlier = Math.max(bestEarlier, previous[index - 1]! + index - 1)
93
+ }
94
+ if (candidate[index] !== needle[queryIndex]) continue
95
+
96
+ const boundaryBonus = isWordBoundary(candidate, index) ? 8 : 0
97
+ if (queryIndex === 0) {
98
+ current[index] = 10 + boundaryBonus - index
99
+ bestScore = Math.max(bestScore, current[index]!)
100
+ continue
101
+ }
102
+
103
+ const contiguous =
104
+ index > 0 ? previous[index - 1]! + 12 : Number.NEGATIVE_INFINITY
105
+ const gapped = bestEarlier - index + 1
106
+ current[index] = Math.max(contiguous, gapped) + 10 + boundaryBonus
107
+ bestScore = Math.max(bestScore, current[index]!)
108
+ }
109
+ if (!Number.isFinite(bestScore)) return null
110
+ const swap = previous
111
+ previous = current
112
+ current = swap
113
+ }
114
+
115
+ return bestScore - candidate.length / 1000
116
+ }
117
+
118
+ export const fuzzyChoices = (
119
+ choices: readonly InteractiveChoice[],
120
+ query: string,
121
+ ) => {
122
+ if (!query) return choices
123
+ return choices
124
+ .map((choice, index) => ({
125
+ choice,
126
+ index,
127
+ score: fuzzyScore(choice.label, query),
128
+ }))
129
+ .filter(
130
+ (
131
+ match,
132
+ ): match is typeof match & {
133
+ score: number
134
+ } => match.score !== null,
135
+ )
136
+ .sort((left, right) => right.score - left.score || left.index - right.index)
137
+ .map((match) => match.choice)
138
+ }
139
+
140
+ export const InteractiveSelectPrompt = (props: SelectPromptProps) => {
141
+ let input: InputRenderable | undefined
142
+ const [query, setQuery] = createSignal("")
143
+ const [selected, setSelected] = createSignal(0)
144
+ const choices = createMemo(() => fuzzyChoices(props.choices, query()))
145
+ const move = (offset: -1 | 1) => {
146
+ const count = choices().length
147
+ if (count === 0) return
148
+ setSelected((current) => (current + offset + count) % count)
149
+ }
150
+ useKeyboard((key) => {
151
+ if (isCancel(key)) {
152
+ key.preventDefault()
153
+ key.stopPropagation()
154
+ props.onDone(null)
155
+ return
156
+ }
157
+ if (key.name === "up" || (key.ctrl && key.name === "p")) {
158
+ key.preventDefault()
159
+ key.stopPropagation()
160
+ move(-1)
161
+ return
162
+ }
163
+ if (key.name === "down" || (key.ctrl && key.name === "n")) {
164
+ key.preventDefault()
165
+ key.stopPropagation()
166
+ move(1)
167
+ return
168
+ }
169
+ if (key.name !== "return") return
170
+ key.preventDefault()
171
+ key.stopPropagation()
172
+ const choice = choices()[selected()]
173
+ if (choice) props.onDone(choice.key)
174
+ })
175
+
176
+ const visible = () => {
177
+ const start = Math.min(
178
+ Math.max(selected() - 1, 0),
179
+ Math.max(choices().length - 2, 0),
180
+ )
181
+ return choices()
182
+ .slice(start, start + 2)
183
+ .map((choice, offset) => ({
184
+ choice,
185
+ index: start + offset,
186
+ }))
187
+ }
188
+
189
+ return (
190
+ <box flexDirection="column" width="100%" height="100%">
191
+ <box flexDirection="row" width="100%">
192
+ <text fg="#7aa2f7" flexShrink={1} wrapMode="none">
193
+ {props.prompt}
194
+ </text>
195
+ <text fg="#7aa2f7">{" > "}</text>
196
+ <input
197
+ focused
198
+ flexGrow={1}
199
+ minWidth={8}
200
+ placeholder="filter"
201
+ onInput={(next) => {
202
+ setQuery(next)
203
+ setSelected(0)
204
+ }}
205
+ ref={(next) => {
206
+ input = next
207
+ queueMicrotask(() => {
208
+ if (input && !input.isDestroyed) input.focus()
209
+ })
210
+ }}
211
+ />
212
+ </box>
213
+ <box flexDirection="column" height={2}>
214
+ <For each={visible()} fallback={<text fg="#6c7086">No matches</text>}>
215
+ {({ choice, index }) => (
216
+ <text
217
+ fg={index === selected() ? "#c0caf5" : "#6c7086"}
218
+ wrapMode="none"
219
+ >
220
+ {index === selected() ? "> " : " "}
221
+ {choice.label}
222
+ </text>
223
+ )}
224
+ </For>
225
+ </box>
226
+ <text fg="#6c7086" wrapMode="none">
227
+ enter select | esc cancel | ctrl-n/p or arrows
228
+ </text>
229
+ </box>
230
+ )
231
+ }
232
+
233
+ const shutdown = async (renderer: CliRenderer) => {
234
+ await renderer.idle().catch(() => undefined)
235
+ if (renderer.externalOutputMode === "capture-stdout") {
236
+ renderer.externalOutputMode = "passthrough"
237
+ }
238
+ if (renderer.screenMode === "split-footer")
239
+ renderer.screenMode = "main-screen"
240
+ if (!renderer.isDestroyed) renderer.destroy()
241
+ }
242
+
243
+ async function runInteractive<T>(
244
+ view: (finish: (value: T | null) => void) => JSX.Element,
245
+ ) {
246
+ let finish!: (value: T | null) => void
247
+ let settled = false
248
+ const result = new Promise<T | null>((resolve) => {
249
+ finish = (value) => {
250
+ if (settled) return
251
+ settled = true
252
+ resolve(value)
253
+ }
254
+ })
255
+ let renderer: CliRenderer | undefined
256
+ try {
257
+ renderer = await createCliRenderer({
258
+ ...interactiveRendererConfig,
259
+ onDestroy: () => finish(null),
260
+ })
261
+ await render(() => view(finish), renderer)
262
+ renderer.requestRender()
263
+ return await result
264
+ } finally {
265
+ if (renderer) {
266
+ await shutdown(renderer)
267
+ process.stdout.write("\n")
268
+ }
269
+ }
270
+ }
271
+
272
+ export const promptText = async (prompt: string) => {
273
+ const result = await runInteractive<string>((finish) => (
274
+ <InteractiveTextPrompt prompt={prompt} onDone={finish} />
275
+ ))
276
+ if (result === null) throw new Error("Interactive input cancelled")
277
+ return result
278
+ }
279
+
280
+ export const promptSelect = (
281
+ prompt: string,
282
+ choices: readonly InteractiveChoice[],
283
+ ) =>
284
+ runInteractive<string>((finish) => (
285
+ <InteractiveSelectPrompt
286
+ prompt={prompt}
287
+ choices={choices}
288
+ onDone={finish}
289
+ />
290
+ ))
@@ -35,8 +35,8 @@ reason to edit `agency.json` or `repos/` by hand.
35
35
 
36
36
  - Stop on validation errors, dependency blockers, an unexpected writable
37
37
  repository, or a conflicting active claim.
38
- - Do not begin execution without a claim. `agency work` claims before launch;
39
- external orchestrators use `agency claim` with the revision from context.
38
+ - `agency work` is the local launch flow and marks execution units `working`
39
+ without claiming them. External orchestrators claim before launching runners.
40
40
  - Do not manually create, move, or remove worktrees under `code/`.
41
41
  - Use `agency archive`, rather than moving work item folders manually.
42
42
  - Do not edit bare repositories or repository symlinks under `repos/`.
@@ -56,9 +56,9 @@ is open; if merge was requested, merge remains delivery work.
56
56
  At each closeout trigger (creating or updating a PR, marking it ready, completing
57
57
  a refinement loop, or pausing or handing off completed implementation work):
58
58
 
59
- - Use `agency task status` or `agency phase status` to set the execution unit's
60
- current status. Finish an active claim with the current revision via
61
- `agency finish`.
59
+ - Finish an active claim with the current revision via `agency finish`.
60
+ Otherwise use `agency task status` or `agency phase status` to set the
61
+ execution unit's current status.
62
62
  - Refresh durable delivery context in `TASK.md` or `PHASE.md`, including recorded
63
63
  PR state, current head, diff summary, and verification results after later
64
64
  pushes when those details are maintained there.
@@ -18,15 +18,27 @@ const variables = {
18
18
  }
19
19
 
20
20
  describe("runner commands", () => {
21
- test("uses deterministic fresh and resume commands for built-in presets", () => {
21
+ test("uses promptless interactive commands for built-in presets", () => {
22
22
  expect(
23
23
  resolveRunnerCommand("opencode", undefined, variables, false).argv,
24
- ).toEqual(["opencode", "--prompt", "Read the task."])
24
+ ).toEqual(["opencode"])
25
25
  expect(
26
26
  resolveRunnerCommand("opencode", undefined, variables, true).argv,
27
- ).toEqual(["opencode", "--continue", "--prompt", "Read the task."])
27
+ ).toEqual(["opencode", "--continue"])
28
28
  expect(
29
29
  resolveRunnerCommand("claude", undefined, variables, true).argv,
30
+ ).toEqual(["claude", "--continue"])
31
+ })
32
+
33
+ test("uses autonomous commands when a prompt is requested", () => {
34
+ expect(
35
+ resolveRunnerCommand("opencode", undefined, variables, false, true).argv,
36
+ ).toEqual(["opencode", "--prompt", "Read the task."])
37
+ expect(
38
+ resolveRunnerCommand("opencode", undefined, variables, true, true).argv,
39
+ ).toEqual(["opencode", "--continue", "--prompt", "Read the task."])
40
+ expect(
41
+ resolveRunnerCommand("claude", undefined, variables, true, true).argv,
30
42
  ).toEqual(["claude", "--continue", "Read the task."])
31
43
  })
32
44
 
@@ -35,12 +47,14 @@ describe("runner commands", () => {
35
47
  "custom",
36
48
  {
37
49
  custom: {
38
- command: ["agent", "--target={target}", "{prompt}"],
50
+ command: ["agent"],
51
+ autoCommand: ["agent", "--target={target}", "{prompt}"],
39
52
  environment: { CUSTOM_SESSION: "{sessionId}" },
40
53
  },
41
54
  },
42
55
  variables,
43
56
  false,
57
+ true,
44
58
  )
45
59
 
46
60
  expect(resolved).toEqual({
@@ -53,6 +67,18 @@ describe("runner commands", () => {
53
67
  })
54
68
  })
55
69
 
70
+ test("rejects --auto for configured runners without an auto command", () => {
71
+ expect(() =>
72
+ resolveRunnerCommand(
73
+ "custom",
74
+ { custom: { command: ["agent"] } },
75
+ variables,
76
+ false,
77
+ true,
78
+ ),
79
+ ).toThrow("Runner 'custom' does not support --auto")
80
+ })
81
+
56
82
  test("rejects unknown placeholders", () => {
57
83
  expect(() =>
58
84
  validateRunners({ custom: { command: ["agent", "{unknown}"] } }),
@@ -13,7 +13,9 @@ export interface RunnerCommandVariables {
13
13
 
14
14
  interface RunnerDefinition {
15
15
  readonly command: readonly string[]
16
+ readonly autoCommand?: readonly string[]
16
17
  readonly resumeCommand?: readonly string[]
18
+ readonly autoResumeCommand?: readonly string[]
17
19
  readonly environment?: Readonly<Record<string, string>>
18
20
  }
19
21
 
@@ -30,12 +32,16 @@ const PLACEHOLDERS = new Set<keyof RunnerCommandVariables>([
30
32
 
31
33
  const BUILTIN_RUNNERS: Readonly<Record<string, RunnerDefinition>> = {
32
34
  opencode: {
33
- command: ["opencode", "--prompt", "{prompt}"],
34
- resumeCommand: ["opencode", "--continue", "--prompt", "{prompt}"],
35
+ command: ["opencode"],
36
+ autoCommand: ["opencode", "--prompt", "{prompt}"],
37
+ resumeCommand: ["opencode", "--continue"],
38
+ autoResumeCommand: ["opencode", "--continue", "--prompt", "{prompt}"],
35
39
  },
36
40
  claude: {
37
- command: ["claude", "{prompt}"],
38
- resumeCommand: ["claude", "--continue", "{prompt}"],
41
+ command: ["claude"],
42
+ autoCommand: ["claude", "{prompt}"],
43
+ resumeCommand: ["claude", "--continue"],
44
+ autoResumeCommand: ["claude", "--continue", "{prompt}"],
39
45
  },
40
46
  }
41
47
 
@@ -54,7 +60,9 @@ export const validateRunners = (runners: WorkbaseConfig["runners"]): void => {
54
60
  for (const [name, runner] of Object.entries(runners ?? {})) {
55
61
  for (const value of [
56
62
  ...runner.command,
63
+ ...(runner.autoCommand ?? []),
57
64
  ...(runner.resumeCommand ?? []),
65
+ ...(runner.autoResumeCommand ?? []),
58
66
  ...Object.values(runner.environment ?? {}),
59
67
  ]) {
60
68
  validateTemplate(name, value)
@@ -74,14 +82,21 @@ export const resolveRunnerCommand = (
74
82
  configured: WorkbaseConfig["runners"],
75
83
  variables: RunnerCommandVariables,
76
84
  resume: boolean,
85
+ auto = false,
77
86
  ) => {
78
87
  validateRunners(configured)
79
88
  const definition = configured?.[name] ?? BUILTIN_RUNNERS[name]
80
89
  if (!definition) throw new Error(`Unknown runner: ${name}`)
81
- const template =
82
- resume && definition.resumeCommand
90
+ const template = auto
91
+ ? resume
92
+ ? (definition.autoResumeCommand ?? definition.autoCommand)
93
+ : definition.autoCommand
94
+ : resume && definition.resumeCommand
83
95
  ? definition.resumeCommand
84
96
  : definition.command
97
+ if (!template) {
98
+ throw new Error(`Runner '${name}' does not support --auto`)
99
+ }
85
100
  const argv = template.map((argument) => expand(argument, variables))
86
101
  const environment = Object.fromEntries(
87
102
  Object.entries(definition.environment ?? {}).map(([key, value]) => [
@@ -136,14 +136,16 @@ describe("runner configuration", () => {
136
136
  version: 2,
137
137
  runners: {
138
138
  custom: {
139
- command: ["agent", "{prompt}"],
139
+ command: ["agent"],
140
+ autoCommand: ["agent", "{prompt}"],
140
141
  resumeCommand: ["agent", "resume", "{sessionId}"],
142
+ autoResumeCommand: ["agent", "resume", "{sessionId}", "{prompt}"],
141
143
  environment: { CUSTOM_TARGET: "{target}" },
142
144
  },
143
145
  },
144
146
  })
145
147
 
146
- expect(config.runners?.custom?.command).toEqual(["agent", "{prompt}"])
148
+ expect(config.runners?.custom?.autoCommand).toEqual(["agent", "{prompt}"])
147
149
  })
148
150
 
149
151
  test("rejects shell strings in place of argv arrays", () => {
@@ -96,7 +96,11 @@ export const WorkbaseConfig = Schema.Struct({
96
96
  key: EntityId,
97
97
  value: Schema.Struct({
98
98
  command: Schema.NonEmptyArray(NonEmptyString),
99
+ autoCommand: Schema.optional(Schema.NonEmptyArray(NonEmptyString)),
99
100
  resumeCommand: Schema.optional(Schema.NonEmptyArray(NonEmptyString)),
101
+ autoResumeCommand: Schema.optional(
102
+ Schema.NonEmptyArray(NonEmptyString),
103
+ ),
100
104
  environment: Schema.optional(
101
105
  Schema.Record({ key: EnvironmentName, value: Schema.String }),
102
106
  ),