@markjaquith/agency 2.32.1 → 2.33.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "2.32.1",
3
+ "version": "2.33.0",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -71,6 +71,8 @@ interface HarnessOptions {
71
71
  readonly guardError?: Error
72
72
  readonly workTargetIds?: readonly string[]
73
73
  readonly opencodeIntegrationState?: "managed" | "customized"
74
+ readonly taskStatus?: "open" | "working" | "delegated" | "done" | "dropped"
75
+ readonly phaseStatus?: "open" | "working" | "delegated" | "done" | "dropped"
74
76
  }
75
77
 
76
78
  const createHarness = (options: HarnessOptions = {}) => {
@@ -141,7 +143,12 @@ const createHarness = (options: HarnessOptions = {}) => {
141
143
  path: `/workbase/tasks/${id}/TASK.md`,
142
144
  data: options.multiPhaseTasks?.includes(id)
143
145
  ? { phases: [] }
144
- : { repo: "agency", branch: `task/${id}`, base: "main" },
146
+ : {
147
+ repo: "agency",
148
+ branch: `task/${id}`,
149
+ base: "main",
150
+ status: options.taskStatus ?? "open",
151
+ },
145
152
  })
146
153
  },
147
154
  list: () => Effect.succeed(options.taskRecords ?? []),
@@ -156,7 +163,12 @@ const createHarness = (options: HarnessOptions = {}) => {
156
163
  taskId,
157
164
  id,
158
165
  path: `/workbase/tasks/${taskId}/phases/${id}/PHASE.md`,
159
- data: { repo: "agency", branch: `task/${id}`, base: "main" },
166
+ data: {
167
+ repo: "agency",
168
+ branch: `task/${id}`,
169
+ base: "main",
170
+ status: options.phaseStatus ?? "open",
171
+ },
160
172
  }),
161
173
  list: () => Effect.succeed(options.phaseRecords ?? []),
162
174
  setStatus: (taskId: string, id: string, status: string) => {
@@ -702,6 +714,22 @@ describe("work command", () => {
702
714
  )
703
715
  })
704
716
 
717
+ test("continues existing work with the resume command", async () => {
718
+ const harness = createHarness({ taskStatus: "working" })
719
+
720
+ await harness.run({ taskId: "example", opencode: true, auto: true })
721
+
722
+ expect(harness.launches[0]?.args).toEqual([
723
+ "opencode",
724
+ "--continue",
725
+ "--prompt",
726
+ "Continue the task. Read /workbase/tasks/example/TASK.md.",
727
+ ])
728
+ expect(harness.launchEnvironments[0]?.AGENCY_PROMPT).toBe(
729
+ "Continue the task. Read /workbase/tasks/example/TASK.md.",
730
+ )
731
+ })
732
+
705
733
  test("resumes OpenCode deterministically when a session identity exists", async () => {
706
734
  const harness = createHarness({ workspace: multiPhaseWorkspace })
707
735
  process.env.AGENCY_SESSION_ID = "existing-session"
@@ -722,7 +750,7 @@ describe("work command", () => {
722
750
  "opencode",
723
751
  "--continue",
724
752
  "--prompt",
725
- "Start the task. Read /workbase/tasks/example/TASK.md and /workbase/tasks/example/phases/implementation/PHASE.md.",
753
+ "Continue the task. Read /workbase/tasks/example/TASK.md and /workbase/tasks/example/phases/implementation/PHASE.md.",
726
754
  ],
727
755
  cwd: taskDirectory,
728
756
  })
@@ -138,6 +138,7 @@ export const work = (
138
138
  taskId: task.id,
139
139
  phaseId: phase.id,
140
140
  path: phase.path,
141
+ status: phase.data.status,
141
142
  }
142
143
  } else {
143
144
  target = {
@@ -145,6 +146,7 @@ export const work = (
145
146
  taskId: task.id,
146
147
  path: task.path,
147
148
  multiPhase: "phases" in task.data,
149
+ status: "phases" in task.data ? undefined : task.data.status,
148
150
  }
149
151
  }
150
152
  } else if (options.directory && !isDirectory) {
@@ -154,6 +156,7 @@ export const work = (
154
156
  taskId: task.id,
155
157
  path: task.path,
156
158
  multiPhase: "phases" in task.data,
159
+ status: "phases" in task.data ? undefined : task.data.status,
157
160
  }
158
161
  } else if (directoryPath) {
159
162
  const path = relative(root, startPath)
@@ -173,6 +176,7 @@ export const work = (
173
176
  taskId: task.id,
174
177
  phaseId: phase.id,
175
178
  path: phase.path,
179
+ status: phase.data.status,
176
180
  }
177
181
  } else {
178
182
  target = {
@@ -180,6 +184,7 @@ export const work = (
180
184
  taskId: task.id,
181
185
  path: task.path,
182
186
  multiPhase: "phases" in task.data,
187
+ status: "phases" in task.data ? undefined : task.data.status,
183
188
  }
184
189
  }
185
190
  }
@@ -224,6 +229,11 @@ export const work = (
224
229
  }
225
230
  yield* readiness.guardWorkTarget(targetNodeId(target), root, options.force)
226
231
 
232
+ const continuing =
233
+ target.kind !== "epic" &&
234
+ !(target.kind === "task" && target.multiPhase) &&
235
+ (process.env.AGENCY_SESSION_ID !== undefined ||
236
+ (target.status !== undefined && target.status !== "open"))
227
237
  let prompt: string
228
238
  let launchPath: string
229
239
  let writablePath: string | undefined
@@ -247,9 +257,10 @@ export const work = (
247
257
  Effect.sync(() => progress.fail("Workspace preparation failed")),
248
258
  ),
249
259
  )
260
+ const action = continuing ? "Continue" : "Start"
250
261
  prompt = workspace.phasePath
251
- ? `Start the task. Read ${workspace.taskPath} and ${workspace.phasePath}.`
252
- : `Start the task. Read ${workspace.taskPath}.`
262
+ ? `${action} the task. Read ${workspace.taskPath} and ${workspace.phasePath}.`
263
+ : `${action} the task. Read ${workspace.taskPath}.`
253
264
  launchPath = dirname(workspace.taskPath)
254
265
  writablePath = workspace.writablePath
255
266
  }
@@ -267,7 +278,9 @@ export const work = (
267
278
  const claimant = process.env.AGENCY_CLAIMANT ?? process.env.USER ?? "agency"
268
279
  const sessionId =
269
280
  process.env.AGENCY_SESSION_ID ?? `${process.pid}-${Date.now()}`
270
- const resume = process.env.AGENCY_SESSION_ID !== undefined
281
+ const resume =
282
+ process.env.AGENCY_SESSION_ID !== undefined ||
283
+ (Boolean(options.auto) && continuing)
271
284
  const variables = {
272
285
  prompt: options.auto ? prompt : "",
273
286
  workbase: root,
@@ -44,7 +44,7 @@ describe("OpenTUI interaction", () => {
44
44
  test("uses the split-footer renderer contract", () => {
45
45
  expect(interactiveRendererConfig).toMatchObject({
46
46
  screenMode: "split-footer",
47
- footerHeight: 4,
47
+ footerHeight: 5,
48
48
  externalOutputMode: "capture-stdout",
49
49
  clearOnShutdown: false,
50
50
  })
@@ -212,6 +212,26 @@ describe("OpenTUI interaction", () => {
212
212
  }
213
213
  })
214
214
 
215
+ test("wraps text prompt input onto another line", async () => {
216
+ const setup = await testRender(
217
+ () => <InteractiveTextPrompt prompt="Task ID" onDone={() => {}} />,
218
+ { width: 16, height: 5 },
219
+ )
220
+ try {
221
+ await setup.renderer.setupTerminal()
222
+ await setup.renderOnce()
223
+ await Bun.sleep(0)
224
+ await setup.mockInput.typeText("alpha beta gamma delta")
225
+ await setup.flush()
226
+ const frame = setup.captureCharFrame()
227
+ expect(frame).toContain("alpha beta")
228
+ expect(frame).toContain("gamma")
229
+ expect(frame).toMatch(/alpha beta\s*\ngamma delta/)
230
+ } finally {
231
+ setup.renderer.destroy()
232
+ }
233
+ })
234
+
215
235
  test("submits text and cancels with ctrl-c or escape", async () => {
216
236
  let submitted: string | null | undefined
217
237
  const input = await testRender(
@@ -4,7 +4,7 @@ import {
4
4
  createCliRenderer,
5
5
  type CliRenderer,
6
6
  type CliRendererConfig,
7
- type InputRenderable,
7
+ type TextareaRenderable,
8
8
  } from "@opentui/core"
9
9
  import { render, useKeyboard, type JSX } from "@opentui/solid"
10
10
  import { createMemo, createSignal, For } from "solid-js"
@@ -16,7 +16,7 @@ export interface InteractiveChoice {
16
16
 
17
17
  export const interactiveRendererConfig = {
18
18
  screenMode: "split-footer",
19
- footerHeight: 4,
19
+ footerHeight: 5,
20
20
  externalOutputMode: "capture-stdout",
21
21
  consoleMode: "disabled",
22
22
  clearOnShutdown: false,
@@ -35,7 +35,7 @@ const isCancel = (key: { name: string; ctrl: boolean }) =>
35
35
  key.name === "escape" || (key.ctrl && key.name === "c")
36
36
 
37
37
  export const InteractiveTextPrompt = (props: PromptProps<string>) => {
38
- let input: InputRenderable | undefined
38
+ let input: TextareaRenderable | undefined
39
39
  let value = ""
40
40
  useKeyboard((key) => {
41
41
  if (isCancel(key)) {
@@ -53,10 +53,13 @@ export const InteractiveTextPrompt = (props: PromptProps<string>) => {
53
53
  return (
54
54
  <box flexDirection="column" width="100%" height="100%">
55
55
  <text fg="#7aa2f7">{props.prompt}</text>
56
- <input
56
+ <textarea
57
57
  focused
58
- onInput={(next) => {
59
- value = next
58
+ height={2}
59
+ wrapMode="word"
60
+ keyBindings={[{ name: "return", action: "submit" }]}
61
+ onContentChange={() => {
62
+ value = input?.plainText ?? ""
60
63
  }}
61
64
  ref={(next) => {
62
65
  input = next
@@ -140,7 +143,7 @@ export const fuzzyChoices = (
140
143
  }
141
144
 
142
145
  export const InteractiveSelectPrompt = (props: SelectPromptProps) => {
143
- let input: InputRenderable | undefined
146
+ let input: TextareaRenderable | undefined
144
147
  const [query, setQuery] = createSignal("")
145
148
  const [selected, setSelected] = createSignal(0)
146
149
  const choices = createMemo(() => fuzzyChoices(props.choices, query()))
@@ -195,13 +198,16 @@ export const InteractiveSelectPrompt = (props: SelectPromptProps) => {
195
198
  {props.prompt}
196
199
  </text>
197
200
  <text fg="#7aa2f7">{" > "}</text>
198
- <input
201
+ <textarea
199
202
  focused
200
203
  flexGrow={1}
201
204
  minWidth={8}
205
+ height={2}
206
+ wrapMode="word"
202
207
  placeholder="filter"
203
- onInput={(next) => {
204
- setQuery(next)
208
+ keyBindings={[{ name: "return", action: "submit" }]}
209
+ onContentChange={() => {
210
+ setQuery(input?.plainText ?? "")
205
211
  setSelected(0)
206
212
  }}
207
213
  ref={(next) => {
@@ -266,7 +272,6 @@ async function runInteractive<T>(
266
272
  } finally {
267
273
  if (renderer) {
268
274
  await shutdown(renderer)
269
- process.stdout.write("\n")
270
275
  }
271
276
  }
272
277
  }
@@ -45,10 +45,13 @@ describe("spawnProcess", () => {
45
45
  const lineCount = 256
46
46
  const script = [
47
47
  `const line = ${JSON.stringify(line)}`,
48
- `for (let i = 0; i < ${lineCount}; i++) {`,
49
- "console.log(`out:${i}:${line}`)",
50
- "console.error(`err:${i}:${line}`)",
51
- "}",
48
+ `const indexes = Array.from({ length: ${lineCount} }, (_, i) => i)`,
49
+ "const stdout = indexes.map((i) => `out:${i}:${line}`).join(`\n`)",
50
+ "const stderr = indexes.map((i) => `err:${i}:${line}`).join(`\n`)",
51
+ "const write = (stream, output) => new Promise((resolve, reject) => {",
52
+ "stream.write(output, (error) => error ? reject(error) : resolve())",
53
+ "})",
54
+ "await Promise.all([write(process.stdout, stdout), write(process.stderr, stderr)])",
52
55
  ].join("\n")
53
56
 
54
57
  const result = await Effect.runPromise(
@@ -13,12 +13,14 @@ export type WorkTarget =
13
13
  readonly taskId: string
14
14
  readonly path: string
15
15
  readonly multiPhase: boolean
16
+ readonly status?: WorkStatus
16
17
  }
17
18
  | {
18
19
  readonly kind: "phase"
19
20
  readonly taskId: string
20
21
  readonly phaseId: string
21
22
  readonly path: string
23
+ readonly status?: WorkStatus
22
24
  }
23
25
 
24
26
  interface EpicRecord {
@@ -116,6 +118,7 @@ const taskChoices = (
116
118
  taskId: task.id,
117
119
  path: task.path,
118
120
  multiPhase,
121
+ status: multiPhase ? undefined : (task.data.status ?? "open"),
119
122
  },
120
123
  },
121
124
  ]
@@ -147,6 +150,7 @@ const taskChoices = (
147
150
  taskId: task.id,
148
151
  phaseId: record.id,
149
152
  path: record.path,
153
+ status: record.data.status ?? "open",
150
154
  },
151
155
  })
152
156
  }
@@ -172,6 +176,7 @@ const taskChoices = (
172
176
  taskId: task.id,
173
177
  phaseId: record.id,
174
178
  path: record.path,
179
+ status: record.data.status ?? "open",
175
180
  },
176
181
  })
177
182
  }