@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 +4 -2
- package/package.json +1 -1
- package/src/cli-parser.ts +2 -2
- package/src/commands/sync.ts +4 -3
- package/src/services/SyncService.ts +57 -13
- package/src/workbase/opencode-v2-tui-plugin-file.ts +108 -21
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
|
|
330
|
-
|
|
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
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"],
|
package/src/commands/sync.ts
CHANGED
|
@@ -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
|
|
131
|
-
queried or reconciled. 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
|
|
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 =
|
|
251
|
-
? allTaskRecords.filter((task) => task.id ===
|
|
252
|
-
:
|
|
253
|
-
|
|
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 '${
|
|
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 (
|
|
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 (!
|
|
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 =
|
|
323
|
+
const reviewRecords = phaseId
|
|
285
324
|
? []
|
|
286
325
|
: taskRecords.filter((task) => "review" in task.data)
|
|
287
|
-
if (
|
|
326
|
+
if (phaseId && records.length === 0) {
|
|
288
327
|
return yield* new SyncError({
|
|
289
|
-
message: `Phase '${
|
|
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
|
-
...(
|
|
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 =
|
|
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
|
-
|
|
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: {
|
|
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
|
|
83
|
-
|
|
84
|
-
if (
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
197
|
+
observe("marker", "autonomous prompt detected")
|
|
198
|
+
void attempt()
|
|
112
199
|
return stop
|
|
113
200
|
}
|
|
114
201
|
}
|