@markjaquith/agency 3.4.2 → 3.5.1

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
@@ -326,8 +326,10 @@ which open a fresh TUI session so the generated continuation prompt cannot be
326
326
  routed to an unrelated prior session. By default Agency opens the agent without
327
327
  a prompt. `--auto` uses its autonomous command and sends the generated task,
328
328
  phase, or epic prompt. OpenCode V2 receives a launch-only environment marker;
329
- Agency's managed TUI companion waits for the populated composer and dispatches
330
- its native submit command once.
329
+ Agency's managed TUI companion retries the native submit command until the exact
330
+ prompt appears as a persisted user message. It records whether OpenCode submitted
331
+ the prompt natively or submission followed a companion dispatch, and shows a
332
+ bounded error with a manual recovery instruction when delivery is not observed.
331
333
 
332
334
  Custom agents are direct argv commands, never shell snippets:
333
335
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "3.4.2",
3
+ "version": "3.5.1",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
package/src/cli-parser.ts CHANGED
@@ -665,14 +665,14 @@ const commands = {
665
665
  },
666
666
  },
667
667
  sync: {
668
- usage: "agency sync [<task-id> [phase-id]] [--dry-run] [--json]",
668
+ usage: "agency sync [<task-id|path> [phase-id]] [--dry-run] [--json]",
669
669
  options: {
670
670
  ...outputOptions,
671
671
  ...entitySelectorOptions,
672
672
  "dry-run": { type: "boolean" },
673
673
  },
674
674
  command: {
675
- usage: "agency sync [<task-id> [phase-id]] [--dry-run] [--json]",
675
+ usage: "agency sync [<task-id|path> [phase-id]] [--dry-run] [--json]",
676
676
  minArgs: 0,
677
677
  maxArgs: 2,
678
678
  options: ["dry-run", "json", "task", "phase"],
@@ -122,13 +122,14 @@ export const sync = (
122
122
  })
123
123
 
124
124
  export const help = `
125
- Usage: agency sync [<task-id> [phase-id]] [--dry-run] [--json]
125
+ Usage: agency sync [<task-id|path> [phase-id]] [--dry-run] [--json]
126
126
 
127
127
  Compare portable repository declarations and execution state with local Git
128
128
  repositories, worktrees, branches, references, and pull requests.
129
129
  Safe reconciliation transitions are applied by default.
130
- When a task or phase is provided, only that target and its repositories are
131
- queried or reconciled. Task scope includes all phases of a multi-phase task.
130
+ When a task, phase, or item path is provided, only that target and its
131
+ repositories are queried or reconciled. Task and epic scopes include their
132
+ child execution units.
132
133
 
133
134
  Options:
134
135
  --dry-run Report planned safe transitions without changing state
@@ -1,5 +1,5 @@
1
1
  import { Data, Effect, Either } from "effect"
2
- import { dirname, join, resolve } from "node:path"
2
+ import { dirname, join, relative, resolve, sep } from "node:path"
3
3
  import type {
4
4
  PhaseFrontmatter,
5
5
  RepositoryReference,
@@ -228,7 +228,17 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
228
228
  const worktrees = yield* WorktreeService
229
229
  const repositories = yield* RepositoryService
230
230
  const versionControl = yield* VersionControlService
231
- const { root, config } = yield* workbase.loadConfig(options.cwd)
231
+ const cwd = resolve(options.cwd ?? process.cwd())
232
+ const candidate = options.taskId
233
+ ? resolve(cwd, options.taskId)
234
+ : undefined
235
+ const candidateExists =
236
+ candidate !== undefined && !options.phaseId
237
+ ? yield* fs.exists(candidate)
238
+ : false
239
+ const { root, config } = yield* workbase.loadConfig(
240
+ candidateExists ? candidate : cwd,
241
+ )
232
242
  const backend = yield* versionControl.forWorkbase(root)
233
243
  const validation = yield* workbase.validate(root, {
234
244
  includeDocuments: true,
@@ -246,20 +256,49 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
246
256
  })
247
257
  }
248
258
  const documents = validation.documents!
259
+ let taskId = options.taskId
260
+ let phaseId = options.phaseId
261
+ let epicId: string | undefined
262
+ const existingTaskSelector =
263
+ options.taskId !== undefined &&
264
+ documents.tasks.some((task) => task.id === options.taskId)
265
+ if (candidateExists && candidate && !existingTaskSelector) {
266
+ const canonicalPath = yield* fs.realPath(candidate)
267
+ const canonicalRoot = yield* fs.realPath(root)
268
+ const parts = relative(canonicalRoot, canonicalPath).split(sep)
269
+ if (parts[0] === "epics" && parts[1]) {
270
+ epicId = parts[1]
271
+ taskId = undefined
272
+ } else if (parts[0] === "tasks" && parts[1]) {
273
+ taskId = parts[1]
274
+ phaseId = parts[2] === "phases" && parts[3] ? parts[3] : undefined
275
+ } else {
276
+ return yield* new SyncError({
277
+ message: `Sync path does not identify an active task, phase, or epic: ${canonicalPath}`,
278
+ })
279
+ }
280
+ }
281
+ if (epicId && !documents.epics.some((epic) => epic.id === epicId)) {
282
+ return yield* new SyncError({
283
+ message: `Epic '${epicId}' does not exist`,
284
+ })
285
+ }
249
286
  const allTaskRecords = documents.tasks
250
- const taskRecords = options.taskId
251
- ? allTaskRecords.filter((task) => task.id === options.taskId)
252
- : allTaskRecords
253
- if (options.taskId && taskRecords.length === 0) {
287
+ const taskRecords = taskId
288
+ ? allTaskRecords.filter((task) => task.id === taskId)
289
+ : epicId
290
+ ? allTaskRecords.filter((task) => task.data.epic === epicId)
291
+ : allTaskRecords
292
+ if (taskId && taskRecords.length === 0) {
254
293
  return yield* new SyncError({
255
- message: `Task '${options.taskId}' does not exist`,
294
+ message: `Task '${taskId}' does not exist`,
256
295
  })
257
296
  }
258
297
  const records: ExecutionRecord[] = []
259
298
  for (const task of taskRecords) {
260
299
  if ("phases" in task.data) {
261
300
  for (const phase of documents.phasesByTask.get(task.id) ?? []) {
262
- if (options.phaseId && phase.id !== options.phaseId) continue
301
+ if (phaseId && phase.id !== phaseId) continue
263
302
  records.push({
264
303
  key: `phase:${task.id}/${phase.id}`,
265
304
  taskId: task.id,
@@ -270,7 +309,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
270
309
  data: phase.data,
271
310
  })
272
311
  }
273
- } else if (!options.phaseId && !("review" in task.data)) {
312
+ } else if (!phaseId && !("review" in task.data)) {
274
313
  records.push({
275
314
  key: `task:${task.id}`,
276
315
  taskId: task.id,
@@ -281,12 +320,12 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
281
320
  })
282
321
  }
283
322
  }
284
- const reviewRecords = options.phaseId
323
+ const reviewRecords = phaseId
285
324
  ? []
286
325
  : taskRecords.filter((task) => "review" in task.data)
287
- if (options.phaseId && records.length === 0) {
326
+ if (phaseId && records.length === 0) {
288
327
  return yield* new SyncError({
289
- message: `Phase '${options.taskId}/${options.phaseId}' does not exist`,
328
+ message: `Phase '${taskId}/${phaseId}' does not exist`,
290
329
  })
291
330
  }
292
331
  const repositoryAliases = new Set<string>()
@@ -299,10 +338,15 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
299
338
  if ("review" in task.data)
300
339
  repositoryAliases.add(task.data.review.repo)
301
340
  }
341
+ if (epicId) {
342
+ const epic = documents.epics.find((record) => record.id === epicId)!
343
+ for (const reference of epic.data.repos)
344
+ repositoryAliases.add(reference.repo)
345
+ }
302
346
  const repositorySetup = yield* repositories.setup({
303
347
  cwd: root,
304
348
  apply: options.apply === true,
305
- ...(options.taskId ? { aliases: [...repositoryAliases] } : {}),
349
+ ...(taskId || epicId ? { aliases: [...repositoryAliases] } : {}),
306
350
  })
307
351
  options.onProgress?.({
308
352
  stage: "repositories",
@@ -35,16 +35,37 @@ export default plugin
35
35
  `
36
36
 
37
37
  const tuiBody = `const autosubmitTimeoutMs = 10_000
38
- const autosubmitRetryMs = 25
38
+ const autosubmitRetryMs = 100
39
+
40
+ type AutosubmitObservation = {
41
+ event: "marker" | "dispatch" | "submitted" | "timeout" | "error"
42
+ detail?: string
43
+ dispatches: number
44
+ }
45
+
46
+ type AutosubmitMessage = {
47
+ type?: string
48
+ text?: string
49
+ info?: { role?: string }
50
+ role?: string
51
+ parts?: readonly { type?: string; text?: string }[]
52
+ }
39
53
 
40
54
  type AutosubmitContext = {
41
55
  keymap: {
42
56
  commands(): readonly { id?: string }[]
43
57
  dispatch(id: string): unknown
44
58
  }
45
- renderer: { currentFocusedEditor?: unknown }
59
+ data?: {
60
+ session?: {
61
+ message?: {
62
+ sync?(sessionID: string): Promise<unknown>
63
+ list?(sessionID: string): readonly AutosubmitMessage[] | undefined
64
+ }
65
+ }
66
+ }
46
67
  ui: {
47
- router: { current(): { type: string } }
68
+ router: { current(): { type: string; sessionID?: string } }
48
69
  toast: {
49
70
  show(input: {
50
71
  variant: "error"
@@ -57,7 +78,11 @@ type AutosubmitContext = {
57
78
  }
58
79
 
59
80
  export const createAgencyAutosubmit = (
60
- options: { timeoutMs?: number; retryMs?: number } = {},
81
+ options: {
82
+ timeoutMs?: number
83
+ retryMs?: number
84
+ observe?: (observation: AutosubmitObservation) => void
85
+ } = {},
61
86
  ) => {
62
87
  let started = false
63
88
 
@@ -66,49 +91,111 @@ export const createAgencyAutosubmit = (
66
91
  if (process.env.AGENCY_TUI_AUTOSUBMIT !== "1") return () => {}
67
92
  if (!process.env.AGENCY_PROMPT) return () => {}
68
93
  started = true
69
- delete process.env.AGENCY_TUI_AUTOSUBMIT
70
94
 
71
95
  const timeoutMs = options.timeoutMs ?? autosubmitTimeoutMs
72
96
  const retryMs = options.retryMs ?? autosubmitRetryMs
73
97
  const deadline = Date.now() + timeoutMs
98
+ const prompt = process.env.AGENCY_PROMPT
74
99
  let timer: ReturnType<typeof setTimeout> | undefined
75
100
  let stopped = false
101
+ let dispatches = 0
102
+ let lastRoute = "unknown"
103
+
104
+ const observe = (event: AutosubmitObservation["event"], detail?: string) => {
105
+ const observation = { event, detail, dispatches }
106
+ options.observe?.(observation)
107
+ if (
108
+ !options.observe &&
109
+ (event !== "dispatch" || dispatches === 1 || dispatches % 10 === 0)
110
+ ) {
111
+ const suffix = detail ? ": " + detail : ""
112
+ console.info("[agency.tui] autosubmit " + event + suffix)
113
+ }
114
+ }
76
115
 
77
116
  const stop = () => {
78
117
  stopped = true
79
118
  if (timer) clearTimeout(timer)
80
119
  }
81
120
 
82
- const attempt = () => {
83
- if (stopped) return
84
- if (context.ui.router.current().type !== "home") {
85
- stop()
86
- return
121
+ const submitted = async (sessionID: string) => {
122
+ const messages = context.data?.session?.message
123
+ if (!messages?.list) return false
124
+ try {
125
+ await messages.sync?.(sessionID)
126
+ } catch (error) {
127
+ observe("error", "message sync failed: " + String(error))
128
+ return false
87
129
  }
130
+ return (messages.list(sessionID) ?? []).some((message) => {
131
+ const role = message.type === "user" ? "user" : message.info?.role ?? message.role
132
+ const text =
133
+ message.text ??
134
+ (message.parts ?? [])
135
+ .filter((part) => part.type === "text")
136
+ .map((part) => part.text ?? "")
137
+ .join("")
138
+ return role === "user" && text === prompt
139
+ })
140
+ }
88
141
 
89
- const submitReady = context.keymap
90
- .commands()
91
- .some((command) => command.id === "prompt.submit")
92
- if (context.renderer.currentFocusedEditor && submitReady) {
93
- stop()
94
- context.keymap.dispatch("prompt.submit")
95
- return
142
+ const finish = (event: "submitted" | "timeout", detail: string) => {
143
+ stop()
144
+ delete process.env.AGENCY_TUI_AUTOSUBMIT
145
+ observe(event, detail)
146
+ }
147
+
148
+ const attempt = async () => {
149
+ if (stopped) return
150
+ const route = context.ui.router.current()
151
+ lastRoute = route.type
152
+ if (route.type === "session" && route.sessionID) {
153
+ if (await submitted(route.sessionID)) {
154
+ finish(
155
+ "submitted",
156
+ dispatches === 0
157
+ ? "native OpenCode submission observed"
158
+ : "submitted message observed after companion dispatch",
159
+ )
160
+ return
161
+ }
96
162
  }
97
163
 
164
+ if (stopped) return
98
165
  if (Date.now() >= deadline) {
99
- stop()
166
+ finish(
167
+ "timeout",
168
+ "route=" + lastRoute + ", dispatches=" + String(dispatches),
169
+ )
100
170
  context.ui.toast.show({
101
171
  variant: "error",
102
172
  title: "Agency launch",
103
- message: "The task prompt is ready but could not be submitted automatically.",
173
+ message:
174
+ "The task prompt was not observed as submitted after " +
175
+ String(dispatches) +
176
+ " automatic attempt(s). Press Enter to submit it manually.",
104
177
  duration: 8_000,
105
178
  })
106
179
  return
107
180
  }
108
- timer = setTimeout(attempt, retryMs)
181
+
182
+ const submitReady = context.keymap
183
+ .commands()
184
+ .some((command) => command.id === "prompt.submit")
185
+ if (route.type === "home" && submitReady) {
186
+ dispatches += 1
187
+ observe("dispatch", "prompt.submit")
188
+ try {
189
+ await context.keymap.dispatch("prompt.submit")
190
+ } catch (error) {
191
+ observe("error", "dispatch failed: " + String(error))
192
+ }
193
+ }
194
+ if (!stopped) timer = setTimeout(() => void attempt(), retryMs)
109
195
  }
110
196
 
111
- attempt()
197
+ observe("marker", "autonomous prompt detected")
198
+ void attempt()
112
199
  return stop
113
200
  }
114
201
  }