@markjaquith/agency 3.3.1 → 3.4.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
@@ -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.1",
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,55 @@ 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
+ const description = scalar("description")
133
+ return {
134
+ id,
135
+ name: scalar("name") ?? id,
136
+ ...(description === undefined ? {} : { description }),
137
+ location,
138
+ content: match ? raw.slice(match[0].length) : raw,
139
+ }
140
+ }
141
+
96
142
  const agencyContext = async (
97
143
  directory: string,
98
144
  useEnvironmentTarget = true,
@@ -133,11 +179,11 @@ const agencyContext = async (
133
179
  }
134
180
  }
135
181
 
136
- const plugin: Plugin = async ({ directory }) => {
182
+ const plugin: V1Plugin = async ({ directory }) => {
137
183
  const workerSessions = new Map<string, AgencyContext>()
138
184
 
139
185
  return {
140
- config: async (config) => {
186
+ config: async (config: any) => {
141
187
  const context = await agencyContext(directory).catch(() => undefined)
142
188
  const root = process.env.AGENCY_WORKBASE ?? context?.root ?? discoverWorkbase(directory)
143
189
  const checkout = process.env.AGENCY_WRITABLE_CHECKOUT ?? context?.checkout ?? discoverCheckout(directory, root)
@@ -171,14 +217,17 @@ const plugin: Plugin = async ({ directory }) => {
171
217
  config.skills ??= {}
172
218
  config.skills.paths = [...new Set([...(config.skills.paths ?? []), ...paths])]
173
219
  },
174
- "chat.message": async ({ sessionID }, output) => {
220
+ "chat.message": async ({ sessionID }: { sessionID: string }, output: any) => {
175
221
  const launchTarget = workerLaunchTarget(output.parts)
176
222
  if (!launchTarget) return
177
223
  const context = await agencyContext(directory, false).catch(() => undefined)
178
224
  if (context?.target !== launchTarget) return
179
225
  workerSessions.set(sessionID, context)
180
226
  },
181
- "experimental.chat.system.transform": async ({ sessionID }, output) => {
227
+ "experimental.chat.system.transform": async (
228
+ { sessionID }: { sessionID?: string },
229
+ output: any,
230
+ ) => {
182
231
  if (!sessionID) return
183
232
  const context = workerSessions.get(sessionID)
184
233
  if (!context?.target) return
@@ -191,7 +240,7 @@ const plugin: Plugin = async ({ directory }) => {
191
240
  ].filter(Boolean).join(" "),
192
241
  )
193
242
  },
194
- "shell.env": async ({ sessionID }, output) => {
243
+ "shell.env": async ({ sessionID }: { sessionID?: string }, output: any) => {
195
244
  if (!sessionID) return
196
245
  const context = workerSessions.get(sessionID)
197
246
  if (!context?.target) return
@@ -209,9 +258,17 @@ const plugin: Plugin = async ({ directory }) => {
209
258
  export const AgencyPlugin = plugin
210
259
 
211
260
  const setup = async (context: V2PluginContext) => {
212
- const root = resolve(dirname(fileURLToPath(import.meta.url)), "../..")
261
+ const directory = context.location.directory
262
+ const agency = await agencyContext(directory).catch(() => undefined)
263
+ const root = agency?.root ?? resolve(dirname(fileURLToPath(import.meta.url)), "../..")
264
+ const instructionsPath = join(root, ".agency", "AGENTS.md")
265
+ const instructions = existsSync(instructionsPath)
266
+ ? readFileSync(instructionsPath, "utf8")
267
+ : undefined
213
268
  await context.reference.transform((references) => {
214
- if (references.list().some(([name]) => name === "workbase")) return
269
+ if (
270
+ references.list().some(([name]: [string, unknown]) => name === "workbase")
271
+ ) return
215
272
  references.add("workbase", {
216
273
  type: "local",
217
274
  path: root,
@@ -219,10 +276,49 @@ const setup = async (context: V2PluginContext) => {
219
276
  })
220
277
  })
221
278
 
222
- const paths = checkoutSkillPaths(process.env.AGENCY_WRITABLE_CHECKOUT)
223
- if (paths.length === 0) return
279
+ const paths = checkoutSkillPaths(
280
+ process.env.AGENCY_WRITABLE_CHECKOUT ??
281
+ agency?.checkout ??
282
+ discoverCheckout(directory, root),
283
+ )
224
284
  await context.skill.transform((skills) => {
225
- for (const path of paths) skills.source({ type: "directory", path })
285
+ for (const path of paths) {
286
+ for (const location of skillFiles(path)) {
287
+ skills.add(skillInfo(location) as unknown as Parameters<typeof skills.add>[0])
288
+ }
289
+ }
290
+ })
291
+
292
+ const workerSessions = new Map<string, AgencyContext>()
293
+ await context.permission.hook("evaluate", (event) => {
294
+ if (
295
+ event.action === "external_directory" &&
296
+ event.resources.every((resource: string) => {
297
+ const path = resolve(resource)
298
+ return path === root || path.startsWith(root + sep)
299
+ })
300
+ ) event.effect = "allow"
301
+ })
302
+ await context.session.hook("prompt", async (event) => {
303
+ const launchTarget = event.prompt.text.match(workerLaunchPattern)?.[1]
304
+ if (!launchTarget) return
305
+ const current = await agencyContext(directory, false).catch(() => undefined)
306
+ if (!current || current.target !== launchTarget) return
307
+ workerSessions.set(event.sessionID, current)
308
+ })
309
+ await context.session.hook("context", (event) => {
310
+ if (instructions) event.system.push({ type: "text", text: instructions })
311
+ const current = workerSessions.get(event.sessionID)
312
+ if (!current?.target) return
313
+ event.system.push({
314
+ type: "text",
315
+ text: [
316
+ \`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.\`,
317
+ current.checkout
318
+ ? \`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.\`
319
+ : undefined,
320
+ ].filter(Boolean).join(" "),
321
+ })
226
322
  })
227
323
  }
228
324
 
@@ -230,7 +326,7 @@ export default {
230
326
  id: "agency",
231
327
  setup,
232
328
  server: plugin,
233
- } satisfies PluginModule & { setup: (context: V2PluginContext) => Promise<void> }
329
+ }
234
330
  `
235
331
 
236
332
  const renderManagedWorkbaseOpencodePlugin = (content: string) =>