@markjaquith/agency 3.3.1 → 3.4.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
@@ -364,16 +364,22 @@ before acting; a matching worker performs the task directly and must not invoke
364
364
  generated prompts when their document paths, current directory, and active valid
365
365
  context all agree. External session state is not part of this identity contract.
366
366
  The managed OpenCode plugin validates the marker against Agency context, binds it
367
- to the receiving OpenCode session, injects an explicit active-worker system
368
- instruction, and restores Agency identity for that session's shell environment.
369
- This session bridge is necessary because an OpenCode client can attach to a
370
- long-lived server process that did not inherit the client's launch environment.
367
+ to the receiving OpenCode session, and injects an explicit active-worker system
368
+ instruction. Its V1 integration also restores Agency identity for that session's
369
+ shell environment. OpenCode V2's shell hook is location-scoped and does not
370
+ identify the invoking session, so Agency deliberately avoids leaking one
371
+ session's worker identity into another; the validated marker and injected system
372
+ instruction remain the V2 fallback when the long-lived server did not inherit
373
+ the client's launch environment.
371
374
  The `opencode2` and `opencode` agents remain rooted in their task or epic
372
375
  working directory so the workbase `AGENTS.md` and managed OpenCode config are
373
376
  discovered normally.
374
377
  Agency's managed OpenCode plugin grants the active workbase external-directory
375
- access and adds existing checkout-local `.claude/skills`, `.agents/skills`, and
376
- `.opencode/{skill,skills}` directories to `skills.paths`. The global Pi
378
+ access and exposes existing checkout-local `.claude/skills`, `.agents/skills`,
379
+ and `.opencode/{skill,skills}` definitions. V1 adds those source directories to
380
+ `skills.paths`; V2 registers their discovered skill definitions through the
381
+ plugin API and injects the managed Agency instructions through a session context
382
+ hook. The global Pi
377
383
  extension provides equivalent whole-workbase context and additionally discovers
378
384
  checkout-local `.pi/skills` through Pi's `resources_discover` lifecycle.
379
385
  `agency work` supplies the checkout directly; plain OpenCode and Pi launches
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "3.3.1",
3
+ "version": "3.4.0",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -207,10 +207,11 @@ recursively launch. External session state is never part of worker identity. If
207
207
  the prompt and context disagree, stop and ask the user rather than launching.
208
208
 
209
209
  For OpenCode, Agency's managed plugin validates the generated marker against
210
- `agency context`, binds that identity to the OpenCode session, injects an
211
- active-worker system instruction, and supplies Agency identity to that session's
212
- shell environment. This avoids relying on the environment of OpenCode's
213
- long-lived server process.
210
+ `agency context`, binds that identity to the OpenCode session, and injects an
211
+ active-worker system instruction. The V1 integration also supplies Agency
212
+ identity to that session's shell environment. OpenCode V2's shell hook does not
213
+ identify the invoking session, so the plugin does not leak one session's identity
214
+ into another and instead retains the validated prompt fallback.
214
215
 
215
216
  ## Closeout
216
217
 
@@ -238,9 +239,11 @@ a refinement loop, or pausing or handing off completed implementation work):
238
239
 
239
240
  `agency integration status` reports `managed`, `drifted`, `customized`, or
240
241
  `missing` generated files. Agency keeps these instructions in
241
- `.agency/AGENTS.md`, and its managed OpenCode config loads them automatically.
242
+ `.agency/AGENTS.md`, and its managed OpenCode integration loads them automatically.
242
243
  It also installs a managed server plugin that exposes skills from the
243
- authoritative writable checkout and an explicitly registered TUI companion
244
+ authoritative writable checkout. In OpenCode V2 it injects these managed
245
+ instructions through a session context hook because configured instruction paths
246
+ are not currently loaded. The V1 integration also registers a TUI companion
244
247
  providing `/agency-debug` without submitting an LLM prompt.
245
248
  The workbase-root `AGENTS.md`, when present, belongs entirely to the workbase
246
249
  owner and composes with these instructions through OpenCode's normal discovery.
@@ -253,7 +256,7 @@ OpenCode can access the complete workbase tree, but this filesystem permission
253
256
  does not expand Agency write authority beyond the checkout reported by
254
257
  `agency context`. OpenCode remains rooted in the task or epic directory so the
255
258
  workbase instructions and config compose normally. The managed plugin resolves
256
- the writable checkout from launch context or `agency context`, then adds its
257
- supported skill directories through `skills.paths`; this does not make other
258
- checkout-local OpenCode configuration authoritative. Agents must follow the
259
- authority reported by `agency context`.
259
+ the writable checkout from launch context or `agency context`, then exposes its
260
+ supported skill directories through the applicable OpenCode plugin API; this
261
+ does not make other checkout-local OpenCode configuration authoritative. Agents
262
+ must follow the authority reported by `agency context`.
@@ -6,10 +6,21 @@ const managedHeaderPattern =
6
6
  const checksum = (content: string) =>
7
7
  createHash("sha256").update(content).digest("hex")
8
8
 
9
- const body = `import { existsSync, readFileSync } from "node:fs"
10
- import { dirname, join, resolve, sep } from "node:path"
9
+ const body = `import { existsSync, readFileSync, readdirSync } from "node:fs"
10
+ import { basename, dirname, extname, join, resolve, sep } from "node:path"
11
11
  import { fileURLToPath } from "node:url"
12
- import type { Plugin, PluginModule } from "@opencode-ai/plugin/v1"
12
+ type V1Plugin = (input: { directory: string }) => Promise<Record<string, any>>
13
+ type V2PluginContext = {
14
+ location: { directory: string }
15
+ reference: { transform(callback: (editor: any) => void): Promise<unknown> }
16
+ skill: { transform(callback: (editor: any) => void): Promise<unknown> }
17
+ permission: {
18
+ hook(name: "evaluate", callback: (event: any) => void): Promise<unknown>
19
+ }
20
+ session: {
21
+ hook(name: string, callback: (event: any) => void): Promise<unknown>
22
+ }
23
+ }
13
24
 
14
25
  const workerLaunchPattern = /^Agency worker launch target: ([^.\\s]+)\\./
15
26
 
@@ -21,20 +32,6 @@ type AgencyContext = {
21
32
  phase?: string
22
33
  }
23
34
 
24
- type V2PluginContext = {
25
- reference: {
26
- transform(callback: (references: {
27
- list(): readonly (readonly [string, unknown])[]
28
- add(name: string, source: { type: "local"; path: string; description: string }): void
29
- }) => void): Promise<unknown>
30
- }
31
- skill: {
32
- transform(callback: (skills: {
33
- source(source: { type: "directory"; path: string }): void
34
- }) => void): Promise<unknown>
35
- }
36
- }
37
-
38
35
  const contextTarget = (result: Record<string, any>): string | undefined => {
39
36
  const target = result.target
40
37
  if (target?.kind === "epic") return \`epic:\${target.epicId}\`
@@ -93,6 +90,54 @@ const checkoutSkillPaths = (checkout: string | undefined) => checkout
93
90
  ].filter(existsSync)
94
91
  : []
95
92
 
93
+ const skillFiles = (source: string) => {
94
+ const files: string[] = []
95
+ const visit = (directory: string, root = false) => {
96
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
97
+ const path = join(directory, entry.name)
98
+ if (entry.isDirectory()) visit(path)
99
+ else if (
100
+ entry.isFile() &&
101
+ (entry.name === "SKILL.md" || (root && extname(entry.name) === ".md"))
102
+ ) files.push(path)
103
+ }
104
+ }
105
+ visit(source, true)
106
+ return files
107
+ }
108
+
109
+ const unquote = (value: string) => {
110
+ const trimmed = value.trim()
111
+ if (
112
+ trimmed.length >= 2 &&
113
+ ((trimmed.startsWith('"') && trimmed.endsWith('"')) ||
114
+ (trimmed.startsWith("'") && trimmed.endsWith("'")))
115
+ ) return trimmed.slice(1, -1)
116
+ return trimmed
117
+ }
118
+
119
+ const skillInfo = (location: string) => {
120
+ const raw = readFileSync(location, "utf8")
121
+ const match = raw.match(/^---\\r?\\n([\\s\\S]*?)\\r?\\n---\\r?\\n?/)
122
+ const frontmatter = match?.[1] ?? ""
123
+ const scalar = (name: string) => {
124
+ const value = frontmatter.match(new RegExp(
125
+ "^" + name + "[ \\t]*:[ \\t]*(.+)$", "m",
126
+ ))?.[1]
127
+ return value ? unquote(value) : undefined
128
+ }
129
+ const id = basename(location) === "SKILL.md"
130
+ ? basename(dirname(location))
131
+ : basename(location, extname(location))
132
+ return {
133
+ id,
134
+ name: scalar("name") ?? id,
135
+ description: scalar("description"),
136
+ location,
137
+ content: match ? raw.slice(match[0].length) : raw,
138
+ }
139
+ }
140
+
96
141
  const agencyContext = async (
97
142
  directory: string,
98
143
  useEnvironmentTarget = true,
@@ -133,11 +178,11 @@ const agencyContext = async (
133
178
  }
134
179
  }
135
180
 
136
- const plugin: Plugin = async ({ directory }) => {
181
+ const plugin: V1Plugin = async ({ directory }) => {
137
182
  const workerSessions = new Map<string, AgencyContext>()
138
183
 
139
184
  return {
140
- config: async (config) => {
185
+ config: async (config: any) => {
141
186
  const context = await agencyContext(directory).catch(() => undefined)
142
187
  const root = process.env.AGENCY_WORKBASE ?? context?.root ?? discoverWorkbase(directory)
143
188
  const checkout = process.env.AGENCY_WRITABLE_CHECKOUT ?? context?.checkout ?? discoverCheckout(directory, root)
@@ -171,14 +216,17 @@ const plugin: Plugin = async ({ directory }) => {
171
216
  config.skills ??= {}
172
217
  config.skills.paths = [...new Set([...(config.skills.paths ?? []), ...paths])]
173
218
  },
174
- "chat.message": async ({ sessionID }, output) => {
219
+ "chat.message": async ({ sessionID }: { sessionID: string }, output: any) => {
175
220
  const launchTarget = workerLaunchTarget(output.parts)
176
221
  if (!launchTarget) return
177
222
  const context = await agencyContext(directory, false).catch(() => undefined)
178
223
  if (context?.target !== launchTarget) return
179
224
  workerSessions.set(sessionID, context)
180
225
  },
181
- "experimental.chat.system.transform": async ({ sessionID }, output) => {
226
+ "experimental.chat.system.transform": async (
227
+ { sessionID }: { sessionID?: string },
228
+ output: any,
229
+ ) => {
182
230
  if (!sessionID) return
183
231
  const context = workerSessions.get(sessionID)
184
232
  if (!context?.target) return
@@ -191,7 +239,7 @@ const plugin: Plugin = async ({ directory }) => {
191
239
  ].filter(Boolean).join(" "),
192
240
  )
193
241
  },
194
- "shell.env": async ({ sessionID }, output) => {
242
+ "shell.env": async ({ sessionID }: { sessionID?: string }, output: any) => {
195
243
  if (!sessionID) return
196
244
  const context = workerSessions.get(sessionID)
197
245
  if (!context?.target) return
@@ -209,9 +257,17 @@ const plugin: Plugin = async ({ directory }) => {
209
257
  export const AgencyPlugin = plugin
210
258
 
211
259
  const setup = async (context: V2PluginContext) => {
212
- const root = resolve(dirname(fileURLToPath(import.meta.url)), "../..")
260
+ const directory = context.location.directory
261
+ const agency = await agencyContext(directory).catch(() => undefined)
262
+ const root = agency?.root ?? resolve(dirname(fileURLToPath(import.meta.url)), "../..")
263
+ const instructionsPath = join(root, ".agency", "AGENTS.md")
264
+ const instructions = existsSync(instructionsPath)
265
+ ? readFileSync(instructionsPath, "utf8")
266
+ : undefined
213
267
  await context.reference.transform((references) => {
214
- if (references.list().some(([name]) => name === "workbase")) return
268
+ if (
269
+ references.list().some(([name]: [string, unknown]) => name === "workbase")
270
+ ) return
215
271
  references.add("workbase", {
216
272
  type: "local",
217
273
  path: root,
@@ -219,10 +275,49 @@ const setup = async (context: V2PluginContext) => {
219
275
  })
220
276
  })
221
277
 
222
- const paths = checkoutSkillPaths(process.env.AGENCY_WRITABLE_CHECKOUT)
223
- if (paths.length === 0) return
278
+ const paths = checkoutSkillPaths(
279
+ process.env.AGENCY_WRITABLE_CHECKOUT ??
280
+ agency?.checkout ??
281
+ discoverCheckout(directory, root),
282
+ )
224
283
  await context.skill.transform((skills) => {
225
- for (const path of paths) skills.source({ type: "directory", path })
284
+ for (const path of paths) {
285
+ for (const location of skillFiles(path)) {
286
+ skills.add(skillInfo(location) as unknown as Parameters<typeof skills.add>[0])
287
+ }
288
+ }
289
+ })
290
+
291
+ const workerSessions = new Map<string, AgencyContext>()
292
+ await context.permission.hook("evaluate", (event) => {
293
+ if (
294
+ event.action === "external_directory" &&
295
+ event.resources.every((resource: string) => {
296
+ const path = resolve(resource)
297
+ return path === root || path.startsWith(root + sep)
298
+ })
299
+ ) event.effect = "allow"
300
+ })
301
+ await context.session.hook("prompt", async (event) => {
302
+ const launchTarget = event.prompt.text.match(workerLaunchPattern)?.[1]
303
+ if (!launchTarget) return
304
+ const current = await agencyContext(directory, false).catch(() => undefined)
305
+ if (!current || current.target !== launchTarget) return
306
+ workerSessions.set(event.sessionID, current)
307
+ })
308
+ await context.session.hook("context", (event) => {
309
+ if (instructions) event.system.push({ type: "text", text: instructions })
310
+ const current = workerSessions.get(event.sessionID)
311
+ if (!current?.target) return
312
+ event.system.push({
313
+ type: "text",
314
+ text: [
315
+ \`Agency verified this OpenCode session as the active worker for \${current.target}. Perform the assigned work directly. Do not invoke agency work for this target or launch a replacement worker.\`,
316
+ current.checkout
317
+ ? \`OpenCode remains rooted in the task or phase directory for Agency instructions and context. Treat \${current.checkout} as the default implementation directory: use it for source reads, edits, repository status, builds, tests, formatting, and other repository-local commands. Set each tool's working directory to that checkout when supported; otherwise use absolute paths. Run Agency lifecycle and context commands from the task or phase directory. Any reference checkouts reported by Agency context are read-only.\`
318
+ : undefined,
319
+ ].filter(Boolean).join(" "),
320
+ })
226
321
  })
227
322
  }
228
323
 
@@ -230,7 +325,7 @@ export default {
230
325
  id: "agency",
231
326
  setup,
232
327
  server: plugin,
233
- } satisfies PluginModule & { setup: (context: V2PluginContext) => Promise<void> }
328
+ }
234
329
  `
235
330
 
236
331
  const renderManagedWorkbaseOpencodePlugin = (content: string) =>