@markjaquith/agency 2.66.0 → 2.68.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
@@ -54,8 +54,10 @@ workbase/
54
54
  .opencode/
55
55
  opencode.jsonc # managed @agency subagent, instructions, and reference
56
56
  tui.jsonc # managed TUI plugin registration
57
- plugin/agency-repository-skills.ts # managed workbase access and checkout skills
57
+ plugins/agency-repository-skills.ts # managed workbase access and checkout skills
58
58
  tui/agency-debug.ts # managed /agency-debug TUI diagnostic
59
+ .pi/
60
+ extensions/agency-workbase.ts # managed workbase context and checkout skills
59
61
  agency.json # tracked config and portable repository declarations
60
62
  repos/ # ignored local materializations
61
63
  frontend/ # bare Git repository or symlink
@@ -83,11 +85,11 @@ workbase/
83
85
  ```
84
86
 
85
87
  Agency keeps discovery and other observational commands read-only. Run
86
- `agency integration status` to inspect `.agency/AGENTS.md` and
87
- `.opencode/opencode.jsonc`, `.opencode/tui.jsonc`, and their managed plugin
88
- files, then `agency integration sync` to create missing files or refresh
89
- checksum-safe managed files. Customized files are reported but never
90
- overwritten. Sync also removes checksum-valid retired managed artifacts while
88
+ `agency integration status` to inspect `.agency/AGENTS.md`, the managed
89
+ OpenCode configuration and plugins, and `.pi/extensions/agency-workbase.ts`,
90
+ then `agency integration sync` to create missing files or refresh checksum-safe
91
+ managed files. Customized files are reported but never overwritten. Sync also
92
+ removes checksum-valid retired managed artifacts while
91
93
  preserving customized files at their former paths. The root
92
94
  `AGENTS.md` is user-owned and is not inspected or modified by Agency.
93
95
 
@@ -119,10 +121,23 @@ toast and does not submit a prompt to an LLM. When no writable checkout skill
119
121
  directory is available, server initialization is reported as indeterminate
120
122
  rather than inferred from plugin discovery.
121
123
  OpenCode discovers the config and plugin from task and epic launch directories.
124
+ The plugin uses OpenCode's plural discovery directory and exports both the V1
125
+ server function and the `opencode2` module wrapper. Integration sync migrates a
126
+ checksum-valid legacy singular-path plugin and preserves customized files.
122
127
  The plugin grants whole-workbase access dynamically, while the portable
123
128
  reference advertises that context to agents. Bash and Agency operations must
124
129
  still follow the write authority reported by `agency context`.
125
130
 
131
+ Pi discovers the managed project extension automatically when launched from
132
+ the workbase root after the project is trusted. When launching Pi from a task,
133
+ phase, or epic directory, pass the managed file with `--extension`, for example
134
+ `pi -e /path/to/workbase/.pi/extensions/agency-workbase.ts`. The extension loads
135
+ the managed Agency instructions into Pi's system prompt, advertises the complete
136
+ workbase, and exposes skills from the writable checkout's `.claude/skills`,
137
+ `.agents/skills`, `.opencode/{skill,skills}`, and `.pi/skills` directories.
138
+ Agency context still determines write authority; reference checkouts remain
139
+ read-only.
140
+
126
141
  Repository aliases, the version-control backend, and canonical fetch remotes are
127
142
  declared in tracked `agency.json`; local clones and symlinks remain ignored under
128
143
  `repos/{alias}`. A declaration contains no local path, symlink target, checkout,
@@ -406,11 +421,14 @@ working directory so the workbase `AGENTS.md` and managed OpenCode config are
406
421
  discovered normally.
407
422
  Agency's managed OpenCode plugin grants the active workbase external-directory
408
423
  access and adds existing checkout-local `.claude/skills`, `.agents/skills`, and
409
- `.opencode/{skill,skills}` directories to `skills.paths`.
410
- `agency work` supplies the checkout directly; plain OpenCode launches resolve a
411
- materialized execution-unit checkout through `agency context`. A multi-phase
424
+ `.opencode/{skill,skills}` directories to `skills.paths`. The managed Pi
425
+ extension provides equivalent whole-workbase context and additionally discovers
426
+ checkout-local `.pi/skills` through Pi's `resources_discover` lifecycle.
427
+ `agency work` supplies the checkout directly; plain OpenCode launches and Pi
428
+ launches with the managed extension loaded resolve a materialized execution-unit
429
+ checkout through `agency context`. A multi-phase
412
430
  task root has no single checkout, so launch from its phase directory when using
413
- plain OpenCode. Other checkout-local configuration is not composed.
431
+ plain OpenCode or Pi. Other checkout-local configuration is not composed.
414
432
  `--print-command` prints the exact cwd and argv plus non-secret environment keys
415
433
  without launching the runner.
416
434
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "2.66.0",
3
+ "version": "2.68.0",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
package/src/cli.test.ts CHANGED
@@ -642,6 +642,7 @@ exit 23
642
642
  { name: "opencode-plugin", state: "managed" },
643
643
  { name: "opencode-tui", state: "managed" },
644
644
  { name: "opencode-tui-plugin", state: "managed" },
645
+ { name: "pi-extension", state: "managed" },
645
646
  ])
646
647
 
647
648
  const synced = parseJson(
@@ -653,6 +654,7 @@ exit 23
653
654
  { name: "opencode-plugin", state: "managed", changed: false },
654
655
  { name: "opencode-tui", state: "managed", changed: false },
655
656
  { name: "opencode-tui-plugin", state: "managed", changed: false },
657
+ { name: "pi-extension", state: "managed", changed: false },
656
658
  ])
657
659
  })
658
660
 
@@ -74,7 +74,7 @@ describe("init command", () => {
74
74
  await Bun.file(join(root, ".opencode/command/agency.md")).exists(),
75
75
  ).toBe(false)
76
76
  const plugin = await Bun.file(
77
- join(root, ".opencode/plugin/agency-repository-skills.ts"),
77
+ join(root, ".opencode/plugins/agency-repository-skills.ts"),
78
78
  ).text()
79
79
  expect(plugin).toContain("AGENCY_WRITABLE_CHECKOUT")
80
80
  expect(plugin).toContain("config.skills.paths")
@@ -89,6 +89,12 @@ describe("init command", () => {
89
89
  expect(tuiPlugin).toContain('slashName: "agency-debug"')
90
90
  expect(tuiPlugin).toContain("api.ui.toast")
91
91
  expect(tuiPlugin).not.toContain("chat.message")
92
+ const piExtension = await Bun.file(
93
+ join(root, ".pi/extensions/agency-workbase.ts"),
94
+ ).text()
95
+ expect(piExtension).toContain('pi.on("resources_discover"')
96
+ expect(piExtension).toContain('pi.on("before_agent_start"')
97
+ expect(piExtension).toContain("AGENCY_WRITABLE_CHECKOUT")
92
98
  })
93
99
 
94
100
  test("preserves existing gitignore entries", async () => {
@@ -51,6 +51,11 @@ describe("integration command", () => {
51
51
  state: "missing",
52
52
  remediation: expect.stringContaining("integration sync"),
53
53
  },
54
+ {
55
+ name: "pi-extension",
56
+ state: "missing",
57
+ remediation: expect.stringContaining("integration sync"),
58
+ },
54
59
  ],
55
60
  })
56
61
  })
@@ -87,7 +92,7 @@ OpenCode config: missing
87
92
  Action: Run 'agency integration sync' to install Agency instructions and whole-workbase OpenCode access.
88
93
 
89
94
  OpenCode workbase plugin: missing
90
- Path: .opencode/plugin/agency-repository-skills.ts
95
+ Path: .opencode/plugins/agency-repository-skills.ts
91
96
  The managed OpenCode workbase plugin needs synchronization.
92
97
  Action: Run 'agency integration sync' to provide workbase access and expose writable-checkout skills in OpenCode.
93
98
 
@@ -99,7 +104,12 @@ OpenCode TUI config: missing
99
104
  OpenCode /agency-debug: missing
100
105
  Path: .opencode/tui/agency-debug.ts
101
106
  The managed OpenCode TUI diagnostic companion needs synchronization.
102
- Action: Run 'agency integration sync' to install /agency-debug.`)
107
+ Action: Run 'agency integration sync' to install /agency-debug.
108
+
109
+ Pi workbase extension: missing
110
+ Path: .pi/extensions/agency-workbase.ts
111
+ The managed Pi workbase extension needs synchronization.
112
+ Action: Run 'agency integration sync' to provide workbase context and expose writable-checkout skills in Pi.`)
103
113
  })
104
114
 
105
115
  test("explicitly synchronizes integration files", async () => {
@@ -113,6 +123,7 @@ OpenCode /agency-debug: missing
113
123
  { name: "opencode-plugin", state: "managed", changed: true },
114
124
  { name: "opencode-tui", state: "managed", changed: true },
115
125
  { name: "opencode-tui-plugin", state: "managed", changed: true },
126
+ { name: "pi-extension", state: "managed", changed: true },
116
127
  ])
117
128
  expect(await Bun.file(join(root, "AGENTS.md")).exists()).toBe(false)
118
129
  expect(await Bun.file(join(root, ".agency/AGENTS.md")).exists()).toBe(true)
@@ -124,7 +135,7 @@ OpenCode /agency-debug: missing
124
135
  ).toBe(false)
125
136
  expect(
126
137
  await Bun.file(
127
- join(root, ".opencode/plugin/agency-repository-skills.ts"),
138
+ join(root, ".opencode/plugins/agency-repository-skills.ts"),
128
139
  ).exists(),
129
140
  ).toBe(true)
130
141
  expect(await Bun.file(join(root, ".opencode/tui.jsonc")).exists()).toBe(
@@ -133,6 +144,9 @@ OpenCode /agency-debug: missing
133
144
  expect(
134
145
  await Bun.file(join(root, ".opencode/tui/agency-debug.ts")).exists(),
135
146
  ).toBe(true)
147
+ expect(
148
+ await Bun.file(join(root, ".pi/extensions/agency-workbase.ts")).exists(),
149
+ ).toBe(true)
136
150
  })
137
151
 
138
152
  test("formats integration sync results for people", async () => {
@@ -151,7 +165,7 @@ OpenCode config: synced
151
165
  Agency's managed OpenCode launch config is ready to load Agency instructions and provide whole-workbase read access.
152
166
 
153
167
  OpenCode workbase plugin: synced
154
- Path: .opencode/plugin/agency-repository-skills.ts
168
+ Path: .opencode/plugins/agency-repository-skills.ts
155
169
  Agency's managed OpenCode plugin provides whole-workbase access and exposes writable-checkout skills.
156
170
 
157
171
  OpenCode TUI config: synced
@@ -160,6 +174,10 @@ OpenCode TUI config: synced
160
174
 
161
175
  OpenCode /agency-debug: synced
162
176
  Path: .opencode/tui/agency-debug.ts
163
- Agency's managed OpenCode TUI diagnostic companion is current.`)
177
+ Agency's managed OpenCode TUI diagnostic companion is current.
178
+
179
+ Pi workbase extension: synced
180
+ Path: .pi/extensions/agency-workbase.ts
181
+ Agency's managed Pi extension provides whole-workbase context and exposes writable-checkout skills.`)
164
182
  })
165
183
  })
@@ -18,6 +18,7 @@ interface IntegrationResult {
18
18
  | "opencode-plugin"
19
19
  | "opencode-tui"
20
20
  | "opencode-tui-plugin"
21
+ | "pi-extension"
21
22
  readonly path: string
22
23
  readonly state: string
23
24
  readonly diagnostic: string
@@ -32,6 +33,7 @@ const integrationNames = {
32
33
  "opencode-plugin": "OpenCode workbase plugin",
33
34
  "opencode-tui": "OpenCode TUI config",
34
35
  "opencode-tui-plugin": "OpenCode /agency-debug",
36
+ "pi-extension": "Pi workbase extension",
35
37
  } as const
36
38
 
37
39
  const logHumanResult = (
@@ -89,10 +91,10 @@ export const help = `
89
91
  Usage: agency integration <subcommand>
90
92
 
91
93
  Inspect or explicitly synchronize managed agent integration files. OpenCode
92
- launches load the managed instructions and project config at runtime. The
93
- managed plugin provides whole-workbase access and writable-checkout skills, and
94
- the /agency-debug TUI diagnostic reports integration state without changing
95
- Agency write authority.
94
+ launches load the managed instructions and project config at runtime. Managed
95
+ OpenCode and Pi extensions provide whole-workbase context and writable-checkout
96
+ skills, and the /agency-debug TUI diagnostic reports OpenCode integration state
97
+ without changing Agency write authority.
96
98
 
97
99
  Subcommands:
98
100
  status Report file state, access diagnostics, and safe remediation
@@ -16,6 +16,10 @@ import {
16
16
  canUpdateManagedWorkbaseOpencodeTuiPlugin,
17
17
  managedWorkbaseOpencodeTuiPlugin,
18
18
  } from "../workbase/opencode-tui-plugin-file"
19
+ import {
20
+ canUpdateManagedWorkbasePiExtension,
21
+ managedWorkbasePiExtension,
22
+ } from "../workbase/pi-extension-file"
19
23
  import { IntegrationService } from "./IntegrationService"
20
24
 
21
25
  const write = async (root: string, path: string, content: string) => {
@@ -65,6 +69,7 @@ describe("IntegrationService", () => {
65
69
  "missing",
66
70
  "missing",
67
71
  "missing",
72
+ "missing",
68
73
  ])
69
74
  expect(await Bun.file(join(root, ".agency/AGENTS.md")).exists()).toBe(false)
70
75
 
@@ -72,7 +77,7 @@ describe("IntegrationService", () => {
72
77
  await write(root, ".opencode/opencode.jsonc", managedWorkbaseOpencode)
73
78
  await write(
74
79
  root,
75
- ".opencode/plugin/agency-repository-skills.ts",
80
+ ".opencode/plugins/agency-repository-skills.ts",
76
81
  managedWorkbaseOpencodePlugin,
77
82
  )
78
83
  await write(root, ".opencode/tui.jsonc", managedWorkbaseOpencodeTui)
@@ -81,12 +86,18 @@ describe("IntegrationService", () => {
81
86
  ".opencode/tui/agency-debug.ts",
82
87
  managedWorkbaseOpencodeTuiPlugin,
83
88
  )
89
+ await write(
90
+ root,
91
+ ".pi/extensions/agency-workbase.ts",
92
+ managedWorkbasePiExtension,
93
+ )
84
94
  expect((await status(root)).files.map(({ state }) => state)).toEqual([
85
95
  "managed",
86
96
  "managed",
87
97
  "managed",
88
98
  "managed",
89
99
  "managed",
100
+ "managed",
90
101
  ])
91
102
  })
92
103
 
@@ -104,6 +115,7 @@ describe("IntegrationService", () => {
104
115
  "missing",
105
116
  "missing",
106
117
  "missing",
118
+ "missing",
107
119
  ])
108
120
  })
109
121
 
@@ -126,6 +138,15 @@ describe("IntegrationService", () => {
126
138
  expect(managedWorkbaseOpencodePlugin).toContain(
127
139
  "process.env.AGENCY_WRITABLE_CHECKOUT",
128
140
  )
141
+ expect(managedWorkbaseOpencodePlugin).toContain(
142
+ 'import type { Plugin, PluginModule } from "@opencode-ai/plugin/v1"',
143
+ )
144
+ expect(managedWorkbaseOpencodePlugin).toContain(
145
+ "export const AgencyPlugin = plugin",
146
+ )
147
+ expect(managedWorkbaseOpencodePlugin).toContain('id: "agency"')
148
+ expect(managedWorkbaseOpencodePlugin).toContain("setup,")
149
+ expect(managedWorkbaseOpencodePlugin).toContain("server: plugin")
129
150
  expect(managedWorkbaseOpencodePlugin).toContain(
130
151
  '["agency", "context", ".", "--compact", "--json"]',
131
152
  )
@@ -199,6 +220,131 @@ describe("IntegrationService", () => {
199
220
  ).toBe(false)
200
221
  })
201
222
 
223
+ test("generates a Pi extension with workbase context and checkout skills", async () => {
224
+ expect(managedWorkbasePiExtension).toContain(
225
+ 'import { CONFIG_DIR_NAME, type ExtensionAPI } from "@earendil-works/pi-coding-agent"',
226
+ )
227
+ expect(managedWorkbasePiExtension).toContain('pi.on("resources_discover"')
228
+ expect(managedWorkbasePiExtension).toContain('pi.on("before_agent_start"')
229
+ expect(managedWorkbasePiExtension).toContain(
230
+ 'join(checkout, ".agents", "skills")',
231
+ )
232
+ expect(managedWorkbasePiExtension).toContain(
233
+ 'join(checkout, CONFIG_DIR_NAME, "skills")',
234
+ )
235
+ expect(managedWorkbasePiExtension).toContain(
236
+ "The complete Agency workbase is available",
237
+ )
238
+ expect(managedWorkbasePiExtension).toContain(
239
+ "Agency context remains the authority for writes",
240
+ )
241
+ expect(
242
+ canUpdateManagedWorkbasePiExtension(managedWorkbasePiExtension),
243
+ ).toBe(true)
244
+ expect(
245
+ canUpdateManagedWorkbasePiExtension(
246
+ managedWorkbasePiExtension.replace("resources_discover", "discover"),
247
+ ),
248
+ ).toBe(false)
249
+
250
+ const taskDirectory = join(root, "tasks/example")
251
+ const checkoutPath = join(taskDirectory, "code/agency")
252
+ await write(root, ".agency/AGENTS.md", "# Managed Agency instructions\n")
253
+ await write(
254
+ root,
255
+ "tasks/example/TASK.md",
256
+ "---\nrepo: agency\nstatus: working\n---\n",
257
+ )
258
+ await write(
259
+ root,
260
+ "tasks/example/code/agency/.agents/skills/example/SKILL.md",
261
+ "---\nname: example\ndescription: Example.\n---\n",
262
+ )
263
+ await write(
264
+ root,
265
+ "tasks/example/code/agency/.pi/skills/pi-example/SKILL.md",
266
+ "---\nname: pi-example\ndescription: Pi example.\n---\n",
267
+ )
268
+ const path = join(root, ".pi/extensions/agency-workbase.ts")
269
+ await write(
270
+ root,
271
+ ".pi/extensions/agency-workbase.ts",
272
+ managedWorkbasePiExtension.replace(
273
+ 'import { CONFIG_DIR_NAME, type ExtensionAPI } from "@earendil-works/pi-coding-agent"',
274
+ 'const CONFIG_DIR_NAME = ".pi"\ntype ExtensionAPI = any',
275
+ ),
276
+ )
277
+
278
+ const contextResponse = JSON.stringify({
279
+ version: 1,
280
+ ok: true,
281
+ result: {
282
+ workbase: { root },
283
+ target: {
284
+ kind: "task",
285
+ taskId: "example",
286
+ path: join(taskDirectory, "TASK.md"),
287
+ },
288
+ authority: {
289
+ mode: "execution",
290
+ writable: { checkoutPath },
291
+ },
292
+ documents: { task: { data: { status: "working" } } },
293
+ validation: { valid: true, warnings: [] },
294
+ },
295
+ })
296
+ const handlers = new Map<string, (...args: any[]) => any>()
297
+ const pi = {
298
+ exec: async () => ({ code: 0, stdout: contextResponse, stderr: "" }),
299
+ on: (name: string, handler: (...args: any[]) => any) => {
300
+ handlers.set(name, handler)
301
+ },
302
+ }
303
+ const previousTarget = process.env.AGENCY_TARGET
304
+ const previousWorkbase = process.env.AGENCY_WORKBASE
305
+ const previousCheckout = process.env.AGENCY_WRITABLE_CHECKOUT
306
+ delete process.env.AGENCY_WORKBASE
307
+ delete process.env.AGENCY_WRITABLE_CHECKOUT
308
+ process.env.AGENCY_TARGET = "execution-unit:task/example"
309
+ try {
310
+ const generated = await import(
311
+ `${pathToFileURL(path).href}?pi-extension=${Date.now()}`
312
+ )
313
+ generated.default(pi)
314
+ const resources = await handlers.get("resources_discover")?.({
315
+ cwd: taskDirectory,
316
+ reason: "startup",
317
+ })
318
+ expect(resources.skillPaths).toEqual([
319
+ join(checkoutPath, ".agents/skills"),
320
+ join(checkoutPath, ".pi/skills"),
321
+ ])
322
+
323
+ const prompt = await handlers.get("before_agent_start")?.(
324
+ { systemPrompt: "Base prompt" },
325
+ { cwd: taskDirectory },
326
+ )
327
+ expect(prompt.systemPrompt).toContain("# Managed Agency instructions")
328
+ expect(prompt.systemPrompt).toContain(
329
+ `The complete Agency workbase is available at ${root}`,
330
+ )
331
+ expect(prompt.systemPrompt).toContain(
332
+ "active worker for execution-unit:task/example",
333
+ )
334
+ expect(prompt.systemPrompt).toContain(
335
+ `${checkoutPath} as the default implementation directory`,
336
+ )
337
+ } finally {
338
+ if (previousTarget === undefined) delete process.env.AGENCY_TARGET
339
+ else process.env.AGENCY_TARGET = previousTarget
340
+ if (previousWorkbase === undefined) delete process.env.AGENCY_WORKBASE
341
+ else process.env.AGENCY_WORKBASE = previousWorkbase
342
+ if (previousCheckout === undefined)
343
+ delete process.env.AGENCY_WRITABLE_CHECKOUT
344
+ else process.env.AGENCY_WRITABLE_CHECKOUT = previousCheckout
345
+ }
346
+ })
347
+
202
348
  const testWorkerIdentity = async ({
203
349
  target,
204
350
  launchTarget,
@@ -276,7 +422,12 @@ describe("IntegrationService", () => {
276
422
  task: "example",
277
423
  phase,
278
424
  })
279
- const hooks = await generated.default({ directory: root } as never)
425
+ expect(generated.default).toMatchObject({
426
+ id: "agency",
427
+ setup: expect.any(Function),
428
+ server: generated.AgencyPlugin,
429
+ })
430
+ const hooks = await generated.AgencyPlugin({ directory: root } as never)
280
431
  await hooks["chat.message"]!(
281
432
  { sessionID: "worker-session" } as never,
282
433
  {
@@ -351,6 +502,52 @@ describe("IntegrationService", () => {
351
502
  phase: "build",
352
503
  }))
353
504
 
505
+ test("registers the workbase reference through the OpenCode V2 API", async () => {
506
+ const path = join(root, ".opencode/plugins/agency-repository-skills.ts")
507
+ await write(
508
+ root,
509
+ ".opencode/plugins/agency-repository-skills.ts",
510
+ managedWorkbaseOpencodePlugin,
511
+ )
512
+ const generated = await import(`${pathToFileURL(path).href}?v2-setup`)
513
+ let reference:
514
+ | {
515
+ name: string
516
+ source: { type: string; path: string; description: string }
517
+ }
518
+ | undefined
519
+
520
+ await generated.default.setup({
521
+ reference: {
522
+ transform: async (callback: (references: unknown) => void) =>
523
+ callback({
524
+ list: () => [],
525
+ add: (
526
+ name: string,
527
+ source: {
528
+ type: string
529
+ path: string
530
+ description: string
531
+ },
532
+ ) => {
533
+ reference = { name, source }
534
+ },
535
+ }),
536
+ },
537
+ skill: { transform: async () => {} },
538
+ })
539
+
540
+ expect(reference).toEqual({
541
+ name: "workbase",
542
+ source: {
543
+ type: "local",
544
+ path: root,
545
+ description:
546
+ "Complete Agency workbase context; write authority still comes only from agency context",
547
+ },
548
+ })
549
+ })
550
+
354
551
  test("registers a TUI-only /agency-debug diagnostic", async () => {
355
552
  const config = JSON.parse(managedBody(managedWorkbaseOpencodeTui))
356
553
  expect(config).toEqual({
@@ -673,6 +870,7 @@ describe("IntegrationService", () => {
673
870
  { name: "opencode-plugin", state: "managed", changed: true },
674
871
  { name: "opencode-tui", state: "managed", changed: true },
675
872
  { name: "opencode-tui-plugin", state: "managed", changed: true },
873
+ { name: "pi-extension", state: "managed", changed: true },
676
874
  ])
677
875
  expect(await Bun.file(join(root, "AGENTS.md")).text()).toBe(
678
876
  customRootAgents,
@@ -688,7 +886,7 @@ describe("IntegrationService", () => {
688
886
  ).toBe(false)
689
887
  expect(
690
888
  await Bun.file(
691
- join(root, ".opencode/plugin/agency-repository-skills.ts"),
889
+ join(root, ".opencode/plugins/agency-repository-skills.ts"),
692
890
  ).text(),
693
891
  ).toBe(managedWorkbaseOpencodePlugin)
694
892
  expect(await Bun.file(join(root, ".opencode/tui.jsonc")).text()).toBe(
@@ -697,6 +895,9 @@ describe("IntegrationService", () => {
697
895
  expect(
698
896
  await Bun.file(join(root, ".opencode/tui/agency-debug.ts")).text(),
699
897
  ).toBe(managedWorkbaseOpencodeTuiPlugin)
898
+ expect(
899
+ await Bun.file(join(root, ".pi/extensions/agency-workbase.ts")).text(),
900
+ ).toBe(managedWorkbasePiExtension)
700
901
 
701
902
  await unlink(join(root, ".agency/AGENTS.md"))
702
903
  const second = await sync(root)
@@ -761,6 +962,50 @@ describe("IntegrationService", () => {
761
962
  })
762
963
  })
763
964
 
965
+ test("migrates a checksum-valid plugin from the legacy singular path", async () => {
966
+ const legacyPath = join(
967
+ root,
968
+ ".opencode/plugin/agency-repository-skills.ts",
969
+ )
970
+ const pluginPath = join(
971
+ root,
972
+ ".opencode/plugins/agency-repository-skills.ts",
973
+ )
974
+ await write(
975
+ root,
976
+ ".opencode/plugin/agency-repository-skills.ts",
977
+ managedWorkbaseOpencodePlugin,
978
+ )
979
+
980
+ const result = await sync(root)
981
+
982
+ expect(result.files[2]).toMatchObject({
983
+ name: "opencode-plugin",
984
+ path: pluginPath,
985
+ state: "managed",
986
+ changed: true,
987
+ })
988
+ expect(await Bun.file(pluginPath).text()).toBe(
989
+ managedWorkbaseOpencodePlugin,
990
+ )
991
+ expect(await Bun.file(legacyPath).exists()).toBe(false)
992
+ })
993
+
994
+ test("preserves a user-owned Pi extension", async () => {
995
+ const custom = "export default function customPiExtension() {}\n"
996
+ await write(root, ".pi/extensions/agency-workbase.ts", custom)
997
+
998
+ const result = await sync(root)
999
+ expect(result.files[5]).toMatchObject({
1000
+ name: "pi-extension",
1001
+ state: "customized",
1002
+ changed: false,
1003
+ })
1004
+ expect(
1005
+ await Bun.file(join(root, ".pi/extensions/agency-workbase.ts")).text(),
1006
+ ).toBe(custom)
1007
+ })
1008
+
764
1009
  test("preserves user-owned TUI config and diagnostic plugin", async () => {
765
1010
  const customConfig = '{"theme":"custom"}\n'
766
1011
  const customPlugin =
@@ -23,6 +23,10 @@ import {
23
23
  canUpdateManagedWorkbaseOpencodeTuiPlugin,
24
24
  managedWorkbaseOpencodeTuiPlugin,
25
25
  } from "../workbase/opencode-tui-plugin-file"
26
+ import {
27
+ canUpdateManagedWorkbasePiExtension,
28
+ managedWorkbasePiExtension,
29
+ } from "../workbase/pi-extension-file"
26
30
 
27
31
  type IntegrationFileState = "managed" | "customized" | "missing" | "drifted"
28
32
 
@@ -33,6 +37,7 @@ interface IntegrationFileStatus {
33
37
  | "opencode-plugin"
34
38
  | "opencode-tui"
35
39
  | "opencode-tui-plugin"
40
+ | "pi-extension"
36
41
  readonly path: string
37
42
  readonly state: IntegrationFileState
38
43
  readonly diagnostic: string
@@ -131,6 +136,26 @@ const describe = (
131
136
  "Run 'agency integration sync' to install /agency-debug.",
132
137
  }
133
138
  }
139
+ if (name === "pi-extension") {
140
+ return state === "managed"
141
+ ? {
142
+ diagnostic:
143
+ "Agency's managed Pi extension provides whole-workbase context and exposes writable-checkout skills.",
144
+ remediation: null,
145
+ }
146
+ : state === "customized"
147
+ ? {
148
+ diagnostic:
149
+ "A user-owned Pi workbase extension is present and was preserved.",
150
+ remediation: null,
151
+ }
152
+ : {
153
+ diagnostic:
154
+ "The managed Pi workbase extension needs synchronization.",
155
+ remediation:
156
+ "Run 'agency integration sync' to provide workbase context and expose writable-checkout skills in Pi.",
157
+ }
158
+ }
134
159
 
135
160
  return state === "missing" || state === "drifted"
136
161
  ? {
@@ -179,17 +204,23 @@ const inspect = (root: string) =>
179
204
  const opencodeJsonPath = join(opencodeDirectory, "opencode.json")
180
205
  const tuiPath = join(opencodeDirectory, "tui.jsonc")
181
206
  const tuiJsonPath = join(opencodeDirectory, "tui.json")
182
- const pluginPath = join(
207
+ const legacyPluginPath = join(
183
208
  opencodeDirectory,
184
209
  "plugin",
185
210
  "agency-repository-skills.ts",
186
211
  )
187
- const pluralPluginPath = join(
212
+ const pluginPath = join(
188
213
  opencodeDirectory,
189
214
  "plugins",
190
215
  "agency-repository-skills.ts",
191
216
  )
192
217
  const tuiPluginPath = join(opencodeDirectory, "tui", "agency-debug.ts")
218
+ const piExtensionPath = join(
219
+ root,
220
+ ".pi",
221
+ "extensions",
222
+ "agency-workbase.ts",
223
+ )
193
224
  const files: IntegrationFileStatus[] = []
194
225
 
195
226
  files.push(
@@ -229,11 +260,6 @@ const inspect = (root: string) =>
229
260
 
230
261
  if ((yield* fs.readSymlinkTarget(pluginPath)) !== null) {
231
262
  files.push(fileStatus("opencode-plugin", pluginPath, "customized"))
232
- } else if (
233
- (yield* fs.readSymlinkTarget(pluralPluginPath)) !== null ||
234
- (yield* fs.exists(pluralPluginPath))
235
- ) {
236
- files.push(fileStatus("opencode-plugin", pluralPluginPath, "customized"))
237
263
  } else if (yield* fs.exists(pluginPath)) {
238
264
  files.push(
239
265
  classify(
@@ -244,6 +270,20 @@ const inspect = (root: string) =>
244
270
  canUpdateManagedWorkbaseOpencodePlugin,
245
271
  ),
246
272
  )
273
+ } else if (
274
+ (yield* fs.readSymlinkTarget(legacyPluginPath)) !== null ||
275
+ (yield* fs.exists(legacyPluginPath))
276
+ ) {
277
+ const legacyContent =
278
+ (yield* fs.readSymlinkTarget(legacyPluginPath)) === null
279
+ ? yield* fs.readFile(legacyPluginPath)
280
+ : null
281
+ files.push(
282
+ legacyContent !== null &&
283
+ canUpdateManagedWorkbaseOpencodePlugin(legacyContent)
284
+ ? fileStatus("opencode-plugin", pluginPath, "missing")
285
+ : fileStatus("opencode-plugin", legacyPluginPath, "customized"),
286
+ )
247
287
  } else {
248
288
  files.push(fileStatus("opencode-plugin", pluginPath, "missing"))
249
289
  }
@@ -285,6 +325,22 @@ const inspect = (root: string) =>
285
325
  files.push(fileStatus("opencode-tui-plugin", tuiPluginPath, "missing"))
286
326
  }
287
327
 
328
+ if ((yield* fs.readSymlinkTarget(piExtensionPath)) !== null) {
329
+ files.push(fileStatus("pi-extension", piExtensionPath, "customized"))
330
+ } else if (yield* fs.exists(piExtensionPath)) {
331
+ files.push(
332
+ classify(
333
+ "pi-extension",
334
+ piExtensionPath,
335
+ yield* fs.readFile(piExtensionPath),
336
+ managedWorkbasePiExtension,
337
+ canUpdateManagedWorkbasePiExtension,
338
+ ),
339
+ )
340
+ } else {
341
+ files.push(fileStatus("pi-extension", piExtensionPath, "missing"))
342
+ }
343
+
288
344
  return files
289
345
  })
290
346
 
@@ -319,6 +375,23 @@ const canRemoveLegacyOpencodeCommand = (root: string) =>
319
375
  return createHash("sha256").update(canonical).digest("hex") === match[1]
320
376
  })
321
377
 
378
+ const canRemoveLegacyOpencodePlugin = (root: string) =>
379
+ Effect.gen(function* () {
380
+ const fs = yield* FileSystemService
381
+ const path = join(
382
+ root,
383
+ ".opencode",
384
+ "plugin",
385
+ "agency-repository-skills.ts",
386
+ )
387
+ if (
388
+ (yield* fs.readSymlinkTarget(path)) !== null ||
389
+ !(yield* fs.exists(path))
390
+ )
391
+ return false
392
+ return canUpdateManagedWorkbaseOpencodePlugin(yield* fs.readFile(path))
393
+ })
394
+
322
395
  export class IntegrationService extends Effect.Service<IntegrationService>()(
323
396
  "IntegrationService",
324
397
  {
@@ -343,6 +416,8 @@ export class IntegrationService extends Effect.Service<IntegrationService>()(
343
416
  ) && (yield* canRemoveLegacyAgents(root))
344
417
  const removeLegacyOpencodeCommand =
345
418
  yield* canRemoveLegacyOpencodeCommand(root)
419
+ const removeLegacyOpencodePlugin =
420
+ yield* canRemoveLegacyOpencodePlugin(root)
346
421
  const files: IntegrationSyncFile[] = []
347
422
 
348
423
  for (const status of statuses) {
@@ -356,17 +431,20 @@ export class IntegrationService extends Effect.Service<IntegrationService>()(
356
431
  yield* fs.createDirectory(join(root, ".opencode"))
357
432
  yield* fs.writeFile(status.path, managedWorkbaseOpencode)
358
433
  } else if (status.name === "opencode-plugin") {
359
- yield* fs.createDirectory(join(root, ".opencode", "plugin"))
434
+ yield* fs.createDirectory(join(root, ".opencode", "plugins"))
360
435
  yield* fs.writeFile(status.path, managedWorkbaseOpencodePlugin)
361
436
  } else if (status.name === "opencode-tui") {
362
437
  yield* fs.createDirectory(join(root, ".opencode"))
363
438
  yield* fs.writeFile(status.path, managedWorkbaseOpencodeTui)
364
- } else {
439
+ } else if (status.name === "opencode-tui-plugin") {
365
440
  yield* fs.createDirectory(join(root, ".opencode", "tui"))
366
441
  yield* fs.writeFile(
367
442
  status.path,
368
443
  managedWorkbaseOpencodeTuiPlugin,
369
444
  )
445
+ } else {
446
+ yield* fs.createDirectory(join(root, ".pi", "extensions"))
447
+ yield* fs.writeFile(status.path, managedWorkbasePiExtension)
370
448
  }
371
449
  }
372
450
  files.push({
@@ -376,11 +454,19 @@ export class IntegrationService extends Effect.Service<IntegrationService>()(
376
454
  needsWrite ? "managed" : status.state,
377
455
  ),
378
456
  changed:
379
- needsWrite || (status.name === "agents" && removeLegacyAgents),
457
+ needsWrite ||
458
+ (status.name === "agents" && removeLegacyAgents) ||
459
+ (status.name === "opencode-plugin" &&
460
+ removeLegacyOpencodePlugin),
380
461
  })
381
462
  }
382
463
 
383
464
  if (removeLegacyAgents) yield* fs.deleteFile(join(root, "AGENTS.md"))
465
+ if (removeLegacyOpencodePlugin) {
466
+ yield* fs.deleteFile(
467
+ join(root, ".opencode", "plugin", "agency-repository-skills.ts"),
468
+ )
469
+ }
384
470
  if (removeLegacyOpencodeCommand) {
385
471
  yield* fs.deleteFile(
386
472
  join(root, ".opencode", "command", "agency.md"),
@@ -8,7 +8,8 @@ const checksum = (content: string) =>
8
8
 
9
9
  const body = `import { existsSync, readFileSync } from "node:fs"
10
10
  import { dirname, join, resolve, sep } from "node:path"
11
- import type { Plugin } from "@opencode-ai/plugin"
11
+ import { fileURLToPath } from "node:url"
12
+ import type { Plugin, PluginModule } from "@opencode-ai/plugin/v1"
12
13
 
13
14
  const workerLaunchPattern = /^Agency worker launch target: ([^.\\s]+)\\./
14
15
 
@@ -20,6 +21,20 @@ type AgencyContext = {
20
21
  phase?: string
21
22
  }
22
23
 
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
+
23
38
  const contextTarget = (result: Record<string, any>): string | undefined => {
24
39
  const target = result.target
25
40
  if (target?.kind === "epic") return \`epic:\${target.epicId}\`
@@ -69,6 +84,15 @@ const discoverCheckout = (directory: string, root: string | undefined) => {
69
84
  }
70
85
  }
71
86
 
87
+ const checkoutSkillPaths = (checkout: string | undefined) => checkout
88
+ ? [
89
+ join(checkout, ".claude", "skills"),
90
+ join(checkout, ".agents", "skills"),
91
+ join(checkout, ".opencode", "skill"),
92
+ join(checkout, ".opencode", "skills"),
93
+ ].filter(existsSync)
94
+ : []
95
+
72
96
  const agencyContext = async (
73
97
  directory: string,
74
98
  useEnvironmentTarget = true,
@@ -141,12 +165,7 @@ const plugin: Plugin = async ({ directory }) => {
141
165
 
142
166
  if (!checkout) return
143
167
 
144
- const paths = [
145
- join(checkout, ".claude", "skills"),
146
- join(checkout, ".agents", "skills"),
147
- join(checkout, ".opencode", "skill"),
148
- join(checkout, ".opencode", "skills"),
149
- ].filter(existsSync).map((path) => \`\${path}\${sep}.\`)
168
+ const paths = checkoutSkillPaths(checkout).map((path) => \`\${path}\${sep}.\`)
150
169
  if (paths.length === 0) return
151
170
 
152
171
  config.skills ??= {}
@@ -186,7 +205,31 @@ const plugin: Plugin = async ({ directory }) => {
186
205
  }
187
206
  }
188
207
 
189
- export default plugin
208
+ export const AgencyPlugin = plugin
209
+
210
+ const setup = async (context: V2PluginContext) => {
211
+ const root = resolve(dirname(fileURLToPath(import.meta.url)), "../..")
212
+ await context.reference.transform((references) => {
213
+ if (references.list().some(([name]) => name === "workbase")) return
214
+ references.add("workbase", {
215
+ type: "local",
216
+ path: root,
217
+ description: "Complete Agency workbase context; write authority still comes only from agency context",
218
+ })
219
+ })
220
+
221
+ const paths = checkoutSkillPaths(process.env.AGENCY_WRITABLE_CHECKOUT)
222
+ if (paths.length === 0) return
223
+ await context.skill.transform((skills) => {
224
+ for (const path of paths) skills.source({ type: "directory", path })
225
+ })
226
+ }
227
+
228
+ export default {
229
+ id: "agency",
230
+ setup,
231
+ server: plugin,
232
+ } satisfies PluginModule & { setup: (context: V2PluginContext) => Promise<void> }
190
233
  `
191
234
 
192
235
  const renderManagedWorkbaseOpencodePlugin = (content: string) =>
@@ -0,0 +1,158 @@
1
+ import { createHash } from "node:crypto"
2
+
3
+ const managedHeaderPattern =
4
+ /^\/\/ agency-managed: sha256=([a-f0-9]{64})\r?\n\r?\n/
5
+
6
+ const checksum = (content: string) =>
7
+ createHash("sha256").update(content).digest("hex")
8
+
9
+ const body = `import { existsSync, readFileSync } from "node:fs"
10
+ import { dirname, join, resolve } from "node:path"
11
+ import { CONFIG_DIR_NAME, type ExtensionAPI } from "@earendil-works/pi-coding-agent"
12
+
13
+ type AgencyContext = {
14
+ root?: string
15
+ checkout?: string
16
+ target?: string
17
+ }
18
+
19
+ const contextTarget = (result: Record<string, any>): string | undefined => {
20
+ const target = result.target
21
+ if (target?.kind === "epic") return \`epic:\${target.epicId}\`
22
+ if (target?.kind === "phase") {
23
+ return \`execution-unit:phase/\${target.taskId}/\${target.phaseId}\`
24
+ }
25
+ if (target?.kind === "task") {
26
+ return result.authority?.mode === "execution"
27
+ ? \`execution-unit:task/\${target.taskId}\`
28
+ : \`task:\${target.taskId}\`
29
+ }
30
+ }
31
+
32
+ const discoverWorkbase = (directory: string) => {
33
+ let current = directory
34
+ while (true) {
35
+ if (existsSync(join(current, "agency.json"))) return current
36
+ const parent = dirname(current)
37
+ if (parent === current) return
38
+ current = parent
39
+ }
40
+ }
41
+
42
+ const discoverCheckout = (directory: string, root: string | undefined) => {
43
+ if (!root) return
44
+ let current = directory
45
+ while (current.startsWith(root)) {
46
+ for (const name of ["PHASE.md", "TASK.md"]) {
47
+ const document = join(current, name)
48
+ if (!existsSync(document)) continue
49
+ const repo = readFileSync(document, "utf8").match(/^repo:\\s*([^\\s]+)\\s*$/m)?.[1]
50
+ if (!repo) return
51
+ const checkout = join(current, "code", repo.replace(/^['\"]|['\"]$/g, ""))
52
+ return existsSync(checkout) ? checkout : undefined
53
+ }
54
+ if (current === root) return
55
+ current = dirname(current)
56
+ }
57
+ }
58
+
59
+ const agencyContext = async (
60
+ pi: ExtensionAPI,
61
+ directory: string,
62
+ ): Promise<AgencyContext | undefined> => {
63
+ const task = process.env.AGENCY_TASK_ID
64
+ const phase = process.env.AGENCY_PHASE_ID
65
+ const args = task
66
+ ? ["context", "--task", task, ...(phase ? ["--phase", phase] : []), "--compact", "--json"]
67
+ : ["context", ".", "--compact", "--json"]
68
+ const command = await pi.exec("agency", args, { timeout: 5000 })
69
+ if (command.code !== 0) return
70
+ const envelope = JSON.parse(command.stdout)
71
+ if (envelope.ok !== true) return
72
+ const result = envelope.result ?? {}
73
+ const target = contextTarget(result)
74
+ const document = result.target?.path
75
+ const status = result.documents?.phase?.data?.status ?? result.documents?.task?.data?.status
76
+ if (
77
+ result.validation?.valid !== true ||
78
+ !target ||
79
+ !document ||
80
+ dirname(document) !== resolve(directory) ||
81
+ (target.startsWith("execution-unit:") &&
82
+ (!result.authority?.writable?.checkoutPath || status !== "working"))
83
+ ) return
84
+ return {
85
+ root: result.workbase?.root,
86
+ checkout: result.authority?.writable?.checkoutPath,
87
+ target,
88
+ }
89
+ }
90
+
91
+ const extension = (pi: ExtensionAPI) => {
92
+ const contexts = new Map<string, Promise<AgencyContext | undefined>>()
93
+ const runtimeContext = (directory: string) => {
94
+ let context = contexts.get(directory)
95
+ if (!context) {
96
+ context = agencyContext(pi, directory).catch(() => undefined)
97
+ contexts.set(directory, context)
98
+ }
99
+ return context
100
+ }
101
+
102
+ pi.on("resources_discover", async (event) => {
103
+ const context = await runtimeContext(event.cwd)
104
+ const root = process.env.AGENCY_WORKBASE ?? context?.root ?? discoverWorkbase(event.cwd)
105
+ const checkout = process.env.AGENCY_WRITABLE_CHECKOUT ?? context?.checkout ?? discoverCheckout(event.cwd, root)
106
+ if (!checkout) return
107
+
108
+ const skillPaths = [
109
+ join(checkout, ".claude", "skills"),
110
+ join(checkout, ".agents", "skills"),
111
+ join(checkout, ".opencode", "skill"),
112
+ join(checkout, ".opencode", "skills"),
113
+ join(checkout, CONFIG_DIR_NAME, "skills"),
114
+ ].filter(existsSync)
115
+ if (skillPaths.length === 0) return
116
+ return { skillPaths: [...new Set(skillPaths)] }
117
+ })
118
+
119
+ pi.on("before_agent_start", async (event, ctx) => {
120
+ const context = await runtimeContext(ctx.cwd)
121
+ const root = process.env.AGENCY_WORKBASE ?? context?.root ?? discoverWorkbase(ctx.cwd)
122
+ if (!root) return
123
+ const checkout = process.env.AGENCY_WRITABLE_CHECKOUT ?? context?.checkout ?? discoverCheckout(ctx.cwd, root)
124
+ const instructionsPath = join(root, ".agency", "AGENTS.md")
125
+ const instructions = existsSync(instructionsPath)
126
+ ? readFileSync(instructionsPath, "utf8").trim()
127
+ : undefined
128
+ const activeTarget = process.env.AGENCY_TARGET
129
+ const worker = activeTarget && context?.target === activeTarget
130
+ ? \`Agency verified this Pi session as the active worker for \${activeTarget}. Perform the assigned work directly. Do not invoke agency work for this target or launch a replacement worker.\`
131
+ : undefined
132
+ const access = \`The complete Agency workbase is available at \${root}. Use absolute paths under that root when workbase context is needed. Agency context remains the authority for writes.\`
133
+ const implementation = checkout
134
+ ? \`Pi remains rooted in the task or phase directory for Agency instructions and context. Treat \${checkout} as the default implementation directory for source reads, edits, repository status, builds, tests, formatting, and other repository-local commands. Run Agency lifecycle and context commands from the task or phase directory. Any reference checkouts reported by Agency context are read-only.\`
135
+ : undefined
136
+
137
+ return {
138
+ systemPrompt: [event.systemPrompt, instructions, access, worker, implementation]
139
+ .filter(Boolean)
140
+ .join("\\n\\n"),
141
+ }
142
+ })
143
+ }
144
+
145
+ export default extension
146
+ `
147
+
148
+ const renderManagedWorkbasePiExtension = (content: string) =>
149
+ `// agency-managed: sha256=${checksum(content)}\n\n${content}`
150
+
151
+ export const managedWorkbasePiExtension = renderManagedWorkbasePiExtension(body)
152
+
153
+ export const canUpdateManagedWorkbasePiExtension = (content: string) => {
154
+ const match = content.match(managedHeaderPattern)
155
+ if (!match?.[1]) return false
156
+
157
+ return checksum(content.slice(match[0].length)) === match[1]
158
+ }