opencode-forking-agents-plugin 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 wkronmiller
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,32 @@
1
+ # forking-agents-plugin
2
+
3
+ OpenCode plugin that adds `fork_<agent>` subagents. When you run the **task** tool with `subagent_type` set to `fork_general`, `fork_explore`, or `fork_<name>` for any configured `mode: subagent` agent, the plugin rewrites the call to the base agent and prepends the **parent session transcript** to the task prompt (wrapped in `<parent_session_transcript>`).
4
+
5
+ ## Install
6
+
7
+ In `opencode.json`:
8
+
9
+ ```json
10
+ {
11
+ "$schema": "https://opencode.ai/config.json",
12
+ "plugin": ["forking-agents-plugin"]
13
+ }
14
+ ```
15
+
16
+ OpenCode installs npm plugins automatically (see [Plugins](https://opencode.ai/docs/plugins/)).
17
+
18
+ ## Local development
19
+
20
+ Clone this repo and point `plugin` at the entry file, or use `bun link` / `npm link`.
21
+
22
+ ## Disable
23
+
24
+ Set `OPENCODE_DISABLE_FORK_SUBAGENT_PLUGIN=1`.
25
+
26
+ ## Requirements
27
+
28
+ OpenCode must pass **`listSessionMessages`** on the plugin input (session store, no HTTP). This matches `@opencode-ai/plugin` **1.5.0+** in the main OpenCode repo; the published npm plugin package types may lag—runtime behavior follows OpenCode.
29
+
30
+ ## License
31
+
32
+ MIT
package/bun.lock ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "lockfileVersion": 1,
3
+ "configVersion": 1,
4
+ "workspaces": {
5
+ "": {
6
+ "name": "forking-agents-plugin",
7
+ "devDependencies": {
8
+ "@opencode-ai/plugin": "1.4.3",
9
+ "@types/bun": "1.2.23",
10
+ "typescript": "5.9.2",
11
+ },
12
+ "peerDependencies": {
13
+ "@opencode-ai/plugin": ">=1.4.3",
14
+ },
15
+ },
16
+ },
17
+ "packages": {
18
+ "@opencode-ai/plugin": ["@opencode-ai/plugin@1.4.3", "", { "dependencies": { "@opencode-ai/sdk": "1.4.3", "zod": "4.1.8" }, "peerDependencies": { "@opentui/core": ">=0.1.97", "@opentui/solid": ">=0.1.97" }, "optionalPeers": ["@opentui/core", "@opentui/solid"] }, "sha512-Ob/3tVSIeuMRJBr2O23RtrnC5djRe01Lglx+TwGEmjrH9yDBJ2tftegYLnNEjRoMuzITgq9LD8168p4pzv+U/A=="],
19
+
20
+ "@opencode-ai/sdk": ["@opencode-ai/sdk@1.4.3", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-X0CAVbwoGAjTY2iecpWkx2B+GAa2jSaQKYpJ+xILopeF/OGKZUN15mjqci+L7cEuwLHV5wk3x2TStUOVCa5p0A=="],
21
+
22
+ "@types/bun": ["@types/bun@1.2.23", "", { "dependencies": { "bun-types": "1.2.23" } }, "sha512-le8ueOY5b6VKYf19xT3McVbXqLqmxzPXHsQT/q9JHgikJ2X22wyTW3g3ohz2ZMnp7dod6aduIiq8A14Xyimm0A=="],
23
+
24
+ "@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="],
25
+
26
+ "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
27
+
28
+ "bun-types": ["bun-types@1.2.23", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-R9f0hKAZXgFU3mlrA0YpE/fiDvwV0FT9rORApt2aQVWSuJDzZOyB5QLc0N/4HF57CS8IXJ6+L5E4W1bW6NS2Aw=="],
29
+
30
+ "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
31
+
32
+ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
33
+
34
+ "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
35
+
36
+ "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
37
+
38
+ "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
39
+
40
+ "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
41
+
42
+ "typescript": ["typescript@5.9.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A=="],
43
+
44
+ "undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="],
45
+
46
+ "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
47
+
48
+ "zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="],
49
+ }
50
+ }
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "opencode-forking-agents-plugin",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "license": "MIT",
6
+ "description": "OpenCode plugin: fork_* subagents with parent session transcript prepended to task prompts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./src/index.ts",
10
+ "default": "./src/index.ts"
11
+ }
12
+ },
13
+ "peerDependencies": {
14
+ "@opencode-ai/plugin": ">=1.4.3"
15
+ },
16
+ "devDependencies": {
17
+ "@opencode-ai/plugin": "1.4.3",
18
+ "@types/bun": "1.2.23",
19
+ "typescript": "5.9.2"
20
+ },
21
+ "scripts": {
22
+ "typecheck": "tsc --noEmit",
23
+ "test": "bun test"
24
+ },
25
+ "keywords": ["opencode", "plugin", "subagent", "fork"]
26
+ }
package/src/index.ts ADDED
@@ -0,0 +1,132 @@
1
+ import type { Config, Hooks, PluginInput } from "@opencode-ai/plugin"
2
+
3
+ /** OpenCode passes this once `@opencode-ai/plugin` 1.5.0 ships; expressed locally until then. */
4
+ type Msg = { info: { role: string }; parts: Array<{ type: string; text?: string }> }
5
+ export type ForkPluginInput = PluginInput & {
6
+ listSessionMessages: (input: { sessionID: string }) => Promise<Msg[]>
7
+ }
8
+
9
+ const PREFIX = "fork_"
10
+ const SVC = "plugin.forking-agents"
11
+ const PREVIEW = 4000
12
+ const CAP = 400_000
13
+
14
+ function plog(
15
+ client: PluginInput["client"],
16
+ level: "debug" | "info" | "error" | "warn",
17
+ message: string,
18
+ extra?: Record<string, unknown>,
19
+ ) {
20
+ void client.app.log({
21
+ body: { service: SVC, level, message, extra },
22
+ })
23
+ }
24
+
25
+ function bases(cfg: Config) {
26
+ const out = new Set<string>(["general", "explore"])
27
+ for (const [k, v] of Object.entries(cfg.agent ?? {})) {
28
+ if (v?.disable) continue
29
+ if (k.startsWith(PREFIX)) continue
30
+ if (v?.mode === "subagent") out.add(k)
31
+ }
32
+ if (cfg.agent?.general?.disable) out.delete("general")
33
+ if (cfg.agent?.explore?.disable) out.delete("explore")
34
+ return [...out]
35
+ }
36
+
37
+ function clip(txt: string) {
38
+ if (txt.length <= CAP) return txt
39
+ return `${txt.slice(0, CAP)}\n\n[truncated ${txt.length - CAP} chars]`
40
+ }
41
+
42
+ function line(msg: Msg) {
43
+ const chunks = msg.parts
44
+ .filter((p) => p.type === "text" && typeof p.text === "string" && p.text.trim())
45
+ .map((p) => p.text!)
46
+ if (chunks.length === 0) return ""
47
+ return `${msg.info.role.toUpperCase()}:\n${chunks.join("\n\n")}\n`
48
+ }
49
+
50
+ function transcript(msgs: Msg[]) {
51
+ return clip(msgs.map(line).filter(Boolean).join("\n"))
52
+ }
53
+
54
+ /**
55
+ * Adds `fork_<name>` subagents for each subagent (built-in general/explore plus any `mode: subagent` in config).
56
+ * When the task tool runs with `subagent_type` `fork_*`, rewrites to the base agent and prepends the parent session transcript to the task prompt.
57
+ *
58
+ * Requires OpenCode with plugin input `listSessionMessages`. Set `OPENCODE_DISABLE_FORK_SUBAGENT_PLUGIN=1` to disable.
59
+ */
60
+ export default async function forkSubagentPlugin(input: ForkPluginInput): Promise<Hooks> {
61
+ if (process.env.OPENCODE_DISABLE_FORK_SUBAGENT_PLUGIN === "1") return {}
62
+
63
+ const { client, directory, listSessionMessages } = input
64
+
65
+ return {
66
+ async config(cfg) {
67
+ cfg.agent ??= {}
68
+ for (const base of bases(cfg)) {
69
+ const fork = `${PREFIX}${base}`
70
+ if (cfg.agent[fork] !== undefined) continue
71
+ cfg.agent[fork] = {
72
+ mode: "subagent",
73
+ hidden: true,
74
+ description: `Fork of @${base}: parent session transcript is prepended before your task prompt.`,
75
+ }
76
+ }
77
+ },
78
+ "tool.execute.before": async (hook, out) => {
79
+ if (hook.tool !== "task" || !out.args || typeof out.args !== "object") return
80
+ const args = out.args as { subagent_type?: string; prompt?: string }
81
+ const sub = args.subagent_type
82
+ if (typeof sub !== "string" || !sub.startsWith(PREFIX)) return
83
+ const base = sub.slice(PREFIX.length)
84
+ if (!base) return
85
+ plog(client, "info", "fork task rewrite", {
86
+ sessionID: hook.sessionID,
87
+ callID: hook.callID,
88
+ from: sub,
89
+ to: base,
90
+ directory,
91
+ })
92
+ args.subagent_type = base
93
+ const task = typeof args.prompt === "string" ? args.prompt : ""
94
+ let block = ""
95
+ try {
96
+ const list = await listSessionMessages({ sessionID: hook.sessionID })
97
+ plog(client, "info", "fork parent messages loaded", {
98
+ sessionID: hook.sessionID,
99
+ messageCount: list.length,
100
+ })
101
+ plog(client, "info", "fork parent messages", {
102
+ sessionID: hook.sessionID,
103
+ messageCount: list.length,
104
+ transcriptChars: list.length === 0 ? 0 : transcript(list).length,
105
+ })
106
+ const t = list.length === 0 ? "" : transcript(list)
107
+ plog(client, "debug", "fork transcript preview", {
108
+ head: t.slice(0, PREVIEW),
109
+ })
110
+ if (list.length === 0) block = "[fork-subagent: empty parent transcript]\n\n"
111
+ else block = `<parent_session_transcript>\n${t}\n</parent_session_transcript>\n\n`
112
+ } catch (err) {
113
+ plog(client, "error", "fork parent messages failed", {
114
+ error: err instanceof Error ? err.message : JSON.stringify(err),
115
+ sessionID: hook.sessionID,
116
+ directory,
117
+ })
118
+ block = "[fork-subagent: failed to load parent transcript]\n\n"
119
+ }
120
+ args.prompt = `${block}<task>\n${task}\n</task>`
121
+ plog(client, "info", "fork child prompt built", {
122
+ sessionID: hook.sessionID,
123
+ totalChars: args.prompt.length,
124
+ hasParentBlock: args.prompt.includes("<parent_session_transcript>"),
125
+ preview500: args.prompt.slice(0, 500),
126
+ })
127
+ plog(client, "debug", "fork child prompt preview", {
128
+ head: args.prompt.slice(0, PREVIEW),
129
+ })
130
+ },
131
+ }
132
+ }
@@ -0,0 +1,77 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import forkSubagentPlugin, { type ForkPluginInput } from "../src/index.js"
3
+
4
+ function ctx(over: Partial<ForkPluginInput> = {}): ForkPluginInput {
5
+ return {
6
+ client: {
7
+ app: { log: () => Promise.resolve({} as never) },
8
+ },
9
+ directory: "/tmp",
10
+ worktree: "/tmp",
11
+ project: {} as never,
12
+ serverUrl: new URL("http://localhost:4096"),
13
+ $: undefined as never,
14
+ listSessionMessages: async () => [],
15
+ ...over,
16
+ } as ForkPluginInput
17
+ }
18
+
19
+ describe("forkSubagentPlugin", () => {
20
+ test("config adds fork_* agents for builtins and configured subagents", async () => {
21
+ const hooks = await forkSubagentPlugin(ctx())
22
+ const cfg: { agent?: Record<string, { mode?: string; description?: string; hidden?: boolean }> } = {
23
+ agent: {
24
+ zebra: { mode: "subagent", description: "Zebra" },
25
+ },
26
+ }
27
+ await hooks.config?.(cfg as never)
28
+ expect(cfg.agent?.fork_general?.mode).toBe("subagent")
29
+ expect(cfg.agent?.fork_explore?.hidden).toBe(true)
30
+ expect(cfg.agent?.fork_zebra?.mode).toBe("subagent")
31
+ expect(cfg.agent?.fork_zebra?.description).toContain("Fork of @zebra")
32
+ })
33
+
34
+ test("config does not replace an existing fork entry", async () => {
35
+ const hooks = await forkSubagentPlugin(ctx())
36
+ const cfg = {
37
+ agent: {
38
+ fork_general: { mode: "subagent" as const, description: "custom" },
39
+ },
40
+ }
41
+ await hooks.config?.(cfg as never)
42
+ expect(cfg.agent.fork_general.description).toBe("custom")
43
+ })
44
+
45
+ test("tool.execute.before rewrites fork_* and prepends transcript", async () => {
46
+ const hooks = await forkSubagentPlugin(
47
+ ctx({
48
+ directory: "/proj",
49
+ worktree: "/proj",
50
+ listSessionMessages: async () => [
51
+ {
52
+ info: { role: "user" },
53
+ parts: [{ type: "text", text: "hello" }],
54
+ },
55
+ {
56
+ info: { role: "assistant" },
57
+ parts: [{ type: "text", text: "hi" }],
58
+ },
59
+ ],
60
+ }),
61
+ )
62
+ const fn = hooks["tool.execute.before"]
63
+ expect(fn).toBeDefined()
64
+ const out = {
65
+ args: {
66
+ subagent_type: "fork_explore",
67
+ prompt: "find foo",
68
+ },
69
+ }
70
+ await fn!({ tool: "task", sessionID: "sess-1", callID: "c1" } as never, out as never)
71
+ expect(out.args.subagent_type).toBe("explore")
72
+ expect(out.args.prompt).toContain("<parent_session_transcript>")
73
+ expect(out.args.prompt).toContain("USER:\nhello")
74
+ expect(out.args.prompt).toContain("<task>")
75
+ expect(out.args.prompt).toContain("find foo")
76
+ })
77
+ })
package/tsconfig.json ADDED
@@ -0,0 +1,12 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "strict": true,
7
+ "skipLibCheck": true,
8
+ "noEmit": true,
9
+ "types": ["bun-types"]
10
+ },
11
+ "include": ["src/**/*.ts", "test/**/*.ts"]
12
+ }