@dougbots/avenor-opencode 0.1.0 → 0.2.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 +31 -8
- package/dist/index.js +271 -99
- package/package.json +6 -1
package/README.md
CHANGED
|
@@ -35,11 +35,23 @@ For a project-level config, add the same entry to `.opencode.json` in your repo
|
|
|
35
35
|
|
|
36
36
|
Once registered, the plugin exposes six tools that your OpenCode agent can call directly — no extra configuration required.
|
|
37
37
|
|
|
38
|
+
## Behaviour
|
|
39
|
+
|
|
40
|
+
When a run is dispatched with `avenor_spawn`, the plugin:
|
|
41
|
+
|
|
42
|
+
1. **Blocks by default** — the tool call stays open, updating its title and metadata as the sub-agent runs, exactly like OpenCode's own sub-agent tool calls. The tool returns when the run reaches a terminal state.
|
|
43
|
+
2. **Re-prompts on completion** — when a `wait=false` (fire-and-forget) run finishes, the plugin automatically injects a completion message into the orchestrating session so the LLM picks up without manual polling.
|
|
44
|
+
3. **Routes permissions** — when a sub-agent running under the `opencode-http` backend requests a permission, the plugin injects a re-prompt into the orchestrating session. The LLM can call `avenor_answer_permission` to respond; the normal permission dialog is also shown as a fallback.
|
|
45
|
+
|
|
38
46
|
## Tools
|
|
39
47
|
|
|
40
48
|
### `avenor_spawn`
|
|
41
49
|
|
|
42
|
-
Dispatch an agent run in the current working directory.
|
|
50
|
+
Dispatch an agent run in the current working directory.
|
|
51
|
+
|
|
52
|
+
**Blocking mode (default, `wait=true`):** the tool call shows live progress updates and returns a structured result when the run finishes.
|
|
53
|
+
|
|
54
|
+
**Fire-and-forget (`wait=false`):** returns immediately with `run_id`. The orchestrating session is re-prompted automatically on completion.
|
|
43
55
|
|
|
44
56
|
| Argument | Required | Description |
|
|
45
57
|
|---|---|---|
|
|
@@ -49,6 +61,7 @@ Dispatch an agent run in the current working directory. Returns a `run_id` you c
|
|
|
49
61
|
| `label` | no | Human-readable label for the run |
|
|
50
62
|
| `timeout` | no | Timeout duration (e.g. `3600s`, `30m`) |
|
|
51
63
|
| `model` | no | Model override |
|
|
64
|
+
| `wait` | no | Block until complete (default `true`) |
|
|
52
65
|
| `supervisor_id` | no | Reuse an existing supervisor by socket path |
|
|
53
66
|
|
|
54
67
|
The working directory (`dir`) is injected automatically from your OpenCode session context — it is not a user-facing argument.
|
|
@@ -104,15 +117,25 @@ Shut down the avenor supervisor process and clean up temp files. Call this when
|
|
|
104
117
|
| `supervisor_id` | no | Supervisor to shut down (defaults to the singleton) |
|
|
105
118
|
| `force` | no | Force shutdown instead of graceful |
|
|
106
119
|
|
|
107
|
-
## Typical
|
|
120
|
+
## Typical workflows
|
|
121
|
+
|
|
122
|
+
**Blocking (default):**
|
|
123
|
+
```
|
|
124
|
+
1. avenor_spawn → tool call shows live progress, blocks until done
|
|
125
|
+
2. (avenor_answer_permission → if a permission is routed to this session mid-run)
|
|
126
|
+
3. tool call returns → structured result with status + session_id
|
|
127
|
+
4. avenor_events → inspect detailed output
|
|
128
|
+
5. avenor_follow_up → optionally iterate
|
|
129
|
+
6. avenor_shutdown → clean up when finished
|
|
130
|
+
```
|
|
108
131
|
|
|
132
|
+
**Parallel / fire-and-forget (`wait=false`):**
|
|
109
133
|
```
|
|
110
|
-
1. avenor_spawn
|
|
111
|
-
2.
|
|
112
|
-
3.
|
|
113
|
-
4.
|
|
114
|
-
5.
|
|
115
|
-
6. avenor_shutdown → clean up when finished
|
|
134
|
+
1. avenor_spawn × N → each returns run_id immediately
|
|
135
|
+
2. (session goes idle) → plugin starts monitoring all pending runs
|
|
136
|
+
3. (sub-agent finishes) → plugin re-prompts this session automatically
|
|
137
|
+
4. avenor_answer_permission → if a permission was routed here mid-run
|
|
138
|
+
5. avenor_events → inspect output
|
|
116
139
|
```
|
|
117
140
|
|
|
118
141
|
## Dependencies
|
package/dist/index.js
CHANGED
|
@@ -2,108 +2,280 @@ import { tool } from "@opencode-ai/plugin";
|
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { answerPermissionTool, eventsTool, followUpTool, shutdownTool, spawnTool, statusTool } from "@dougbots/avenor-core";
|
|
4
4
|
//#region src/plugin.ts
|
|
5
|
-
const
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
description: "Resume a completed run with a follow-up message.",
|
|
62
|
-
args: {
|
|
63
|
-
run_id: z.string().describe("Completed run ID to resume"),
|
|
64
|
-
message: z.string().describe("Follow-up message text"),
|
|
65
|
-
label: z.string().optional().describe("Override label (defaults to <original>-followup)"),
|
|
66
|
-
supervisor_id: z.string().optional().describe("Reuse an existing supervisor by socket path")
|
|
67
|
-
},
|
|
68
|
-
async execute(args, _context) {
|
|
69
|
-
return followUpTool({
|
|
70
|
-
runId: args.run_id,
|
|
71
|
-
message: args.message,
|
|
72
|
-
label: args.label,
|
|
73
|
-
supervisorId: args.supervisor_id
|
|
74
|
-
});
|
|
5
|
+
const TERMINAL_STATUSES = new Set([
|
|
6
|
+
"done",
|
|
7
|
+
"failed",
|
|
8
|
+
"timeout",
|
|
9
|
+
"killed"
|
|
10
|
+
]);
|
|
11
|
+
const POLL_INTERVAL_MS = 3e3;
|
|
12
|
+
function sleep(ms) {
|
|
13
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
14
|
+
}
|
|
15
|
+
function buildCompletionText(run, result) {
|
|
16
|
+
const lines = [`Sub-agent "${run.label}" finished with status **${result.status}**.`];
|
|
17
|
+
if (result.stop_reason && result.stop_reason.toUpperCase() !== result.status.toUpperCase()) lines.push(`Stop reason: ${result.stop_reason}`);
|
|
18
|
+
if (result.session_id) lines.push(`Session: \`${result.session_id}\``);
|
|
19
|
+
lines.push(`\nCall \`avenor_events\` with \`run_id: "${run.runId}"\` to review the output, or \`avenor_follow_up\` to iterate.`);
|
|
20
|
+
return lines.join("\n");
|
|
21
|
+
}
|
|
22
|
+
function summarizeEvent(evt) {
|
|
23
|
+
const type = String(evt.type ?? evt.event ?? "");
|
|
24
|
+
if (type === "tool.call" || type === "tool.use") return `called ${String(evt.tool ?? evt.name ?? type)}`;
|
|
25
|
+
if (type === "agent.message_chunk") return "thinking…";
|
|
26
|
+
if (type.startsWith("permission.")) return `permission requested`;
|
|
27
|
+
return type;
|
|
28
|
+
}
|
|
29
|
+
const AvenorPlugin = async (ctx) => {
|
|
30
|
+
const trackedRuns = /* @__PURE__ */ new Map();
|
|
31
|
+
const sessionIdToRunId = /* @__PURE__ */ new Map();
|
|
32
|
+
function registerSessionId(sessionId, runId) {
|
|
33
|
+
if (sessionId && !sessionIdToRunId.has(sessionId)) sessionIdToRunId.set(sessionId, runId);
|
|
34
|
+
}
|
|
35
|
+
async function monitorRun(run) {
|
|
36
|
+
while (true) {
|
|
37
|
+
await sleep(POLL_INTERVAL_MS);
|
|
38
|
+
let raw;
|
|
39
|
+
try {
|
|
40
|
+
raw = await statusTool({
|
|
41
|
+
runId: run.runId,
|
|
42
|
+
supervisorId: run.supervisorId
|
|
43
|
+
});
|
|
44
|
+
} catch {
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
const result = Array.isArray(raw) ? raw[0] : raw;
|
|
48
|
+
if (!result) continue;
|
|
49
|
+
registerSessionId(result.session_id, run.runId);
|
|
50
|
+
if (TERMINAL_STATUSES.has(result.status)) {
|
|
51
|
+
trackedRuns.delete(run.runId);
|
|
52
|
+
await ctx.client.session.promptAsync({
|
|
53
|
+
path: { id: run.orchestratorSessionId },
|
|
54
|
+
body: { parts: [{
|
|
55
|
+
type: "text",
|
|
56
|
+
text: buildCompletionText(run, result)
|
|
57
|
+
}] }
|
|
58
|
+
}).catch(() => {});
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
75
61
|
}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
event: async ({ event }) => {
|
|
65
|
+
if (event.type !== "session.idle") return;
|
|
66
|
+
const { sessionID } = event.properties;
|
|
67
|
+
for (const run of trackedRuns.values()) if (run.orchestratorSessionId === sessionID && !run.monitoring) {
|
|
68
|
+
run.monitoring = true;
|
|
69
|
+
monitorRun(run).catch(console.error);
|
|
70
|
+
}
|
|
84
71
|
},
|
|
85
|
-
async
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
72
|
+
"permission.ask": async (permission, _output) => {
|
|
73
|
+
const runId = sessionIdToRunId.get(permission.sessionID);
|
|
74
|
+
if (!runId) return;
|
|
75
|
+
const run = trackedRuns.get(runId);
|
|
76
|
+
if (!run) return;
|
|
77
|
+
const patternStr = permission.pattern ? [permission.pattern].flat().join(", ") : void 0;
|
|
78
|
+
const lines = [
|
|
79
|
+
`Sub-agent "${run.label}" is requesting permission for \`${permission.type}\`.`,
|
|
80
|
+
...patternStr ? [`Pattern: \`${patternStr}\``] : [],
|
|
81
|
+
``,
|
|
82
|
+
`To answer: call \`avenor_answer_permission\` with:`,
|
|
83
|
+
` run_id: "${runId}"`,
|
|
84
|
+
` request_id: "${permission.id}"`,
|
|
85
|
+
` option_id: "allow_once" | "allow_always" | "deny"`
|
|
86
|
+
];
|
|
87
|
+
await ctx.client.session.promptAsync({
|
|
88
|
+
path: { id: run.orchestratorSessionId },
|
|
89
|
+
body: { parts: [{
|
|
90
|
+
type: "text",
|
|
91
|
+
text: lines.join("\n")
|
|
92
|
+
}] }
|
|
93
|
+
}).catch(() => {});
|
|
99
94
|
},
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
95
|
+
tool: {
|
|
96
|
+
avenor_spawn: tool({
|
|
97
|
+
description: "Dispatch an agent run via avenor. Blocks by default, showing live progress as an updating tool call. Set wait=false for fire-and-forget — you will be re-prompted automatically on completion.",
|
|
98
|
+
args: {
|
|
99
|
+
agent: z.string().describe("Agent name (required, no default)"),
|
|
100
|
+
prompt: z.string().optional().describe("Prompt text"),
|
|
101
|
+
prompt_file: z.string().optional().describe("Path to prompt file"),
|
|
102
|
+
label: z.string().optional().describe("Human-readable label for the run"),
|
|
103
|
+
timeout: z.string().optional().describe("Timeout duration (e.g. 3600s)"),
|
|
104
|
+
model: z.string().optional().describe("Model override"),
|
|
105
|
+
backend: z.string().optional().describe("Backend override"),
|
|
106
|
+
server_url: z.string().optional().describe("Backend server URL"),
|
|
107
|
+
supervisor_id: z.string().optional().describe("Reuse an existing supervisor by socket path"),
|
|
108
|
+
wait: z.boolean().default(true).describe("Block until complete with live status updates. False = fire-and-forget.")
|
|
109
|
+
},
|
|
110
|
+
async execute(args, context) {
|
|
111
|
+
const result = await spawnTool({
|
|
112
|
+
agent: args.agent,
|
|
113
|
+
prompt: args.prompt,
|
|
114
|
+
promptFile: args.prompt_file,
|
|
115
|
+
label: args.label,
|
|
116
|
+
dir: context.directory,
|
|
117
|
+
timeout: args.timeout,
|
|
118
|
+
model: args.model,
|
|
119
|
+
backend: args.backend,
|
|
120
|
+
serverUrl: args.server_url,
|
|
121
|
+
supervisorId: args.supervisor_id
|
|
122
|
+
});
|
|
123
|
+
const supervisorId = result.supervisor_id || args.supervisor_id;
|
|
124
|
+
trackedRuns.set(result.run_id, {
|
|
125
|
+
runId: result.run_id,
|
|
126
|
+
orchestratorSessionId: context.sessionID,
|
|
127
|
+
label: result.label,
|
|
128
|
+
supervisorId,
|
|
129
|
+
monitoring: args.wait
|
|
130
|
+
});
|
|
131
|
+
if (!args.wait) return result;
|
|
132
|
+
context.metadata({ title: `Starting ${result.label}…` });
|
|
133
|
+
let firstPoll = true;
|
|
134
|
+
while (!context.abort.aborted) {
|
|
135
|
+
if (!firstPoll) await sleep(POLL_INTERVAL_MS);
|
|
136
|
+
firstPoll = false;
|
|
137
|
+
if (context.abort.aborted) break;
|
|
138
|
+
let raw;
|
|
139
|
+
try {
|
|
140
|
+
raw = await statusTool({
|
|
141
|
+
runId: result.run_id,
|
|
142
|
+
supervisorId
|
|
143
|
+
});
|
|
144
|
+
} catch {
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
const status = Array.isArray(raw) ? raw[0] : raw;
|
|
148
|
+
if (!status) continue;
|
|
149
|
+
registerSessionId(status.session_id, result.run_id);
|
|
150
|
+
if (status.pending_permission) context.metadata({
|
|
151
|
+
title: `[blocked] ${result.label}`,
|
|
152
|
+
metadata: {
|
|
153
|
+
status: "permission",
|
|
154
|
+
permission: status.pending_permission.description
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
else {
|
|
158
|
+
let lastAction = "";
|
|
159
|
+
if (!supervisorId) try {
|
|
160
|
+
const { events } = await eventsTool({
|
|
161
|
+
runId: result.run_id,
|
|
162
|
+
limit: 3
|
|
163
|
+
});
|
|
164
|
+
const last = events[events.length - 1];
|
|
165
|
+
if (last) lastAction = summarizeEvent(last);
|
|
166
|
+
} catch {}
|
|
167
|
+
context.metadata({
|
|
168
|
+
title: result.label,
|
|
169
|
+
metadata: {
|
|
170
|
+
status: status.status,
|
|
171
|
+
...status.phase_label && { phase: status.phase_label },
|
|
172
|
+
...lastAction && { last: lastAction }
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
if (TERMINAL_STATUSES.has(status.status)) {
|
|
177
|
+
trackedRuns.delete(result.run_id);
|
|
178
|
+
return {
|
|
179
|
+
title: `${result.label} — ${status.status}`,
|
|
180
|
+
output: buildCompletionText({
|
|
181
|
+
runId: result.run_id,
|
|
182
|
+
label: result.label
|
|
183
|
+
}, status),
|
|
184
|
+
metadata: {
|
|
185
|
+
status: status.status,
|
|
186
|
+
run_id: result.run_id,
|
|
187
|
+
session_id: status.session_id
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
trackedRuns.delete(result.run_id);
|
|
193
|
+
return {
|
|
194
|
+
title: `${result.label} — aborted`,
|
|
195
|
+
output: `Monitoring of "${result.label}" (run_id: ${result.run_id}) was interrupted. The run may still be active — use avenor_status to check.`,
|
|
196
|
+
metadata: { run_id: result.run_id }
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
}),
|
|
200
|
+
avenor_status: tool({
|
|
201
|
+
description: "Get status of a run or all runs. Surfaces pending permission requests.",
|
|
202
|
+
args: {
|
|
203
|
+
run_id: z.string().optional().describe("Specific run ID to query"),
|
|
204
|
+
supervisor_id: z.string().optional().describe("Reuse an existing supervisor by socket path")
|
|
205
|
+
},
|
|
206
|
+
async execute(args, _context) {
|
|
207
|
+
return statusTool({
|
|
208
|
+
runId: args.run_id,
|
|
209
|
+
supervisorId: args.supervisor_id
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
}),
|
|
213
|
+
avenor_answer_permission: tool({
|
|
214
|
+
description: "Answer a pending permission request for a sub-agent. Pass option_id from avenor_status pending_permission.options, or use \"allow_once\", \"allow_always\", \"deny\".",
|
|
215
|
+
args: {
|
|
216
|
+
run_id: z.string().describe("Run ID with the pending permission"),
|
|
217
|
+
option_id: z.string().describe("allow_once | allow_always | deny, or option_id from pending_permission.options"),
|
|
218
|
+
request_id: z.string().optional().describe("Permission request ID (auto-discovered if omitted)"),
|
|
219
|
+
supervisor_id: z.string().optional().describe("Reuse an existing supervisor by socket path")
|
|
220
|
+
},
|
|
221
|
+
async execute(args, _context) {
|
|
222
|
+
return answerPermissionTool({
|
|
223
|
+
runId: args.run_id,
|
|
224
|
+
optionId: args.option_id,
|
|
225
|
+
requestId: args.request_id,
|
|
226
|
+
supervisorId: args.supervisor_id
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
}),
|
|
230
|
+
avenor_follow_up: tool({
|
|
231
|
+
description: "Resume a completed run with a follow-up message.",
|
|
232
|
+
args: {
|
|
233
|
+
run_id: z.string().describe("Completed run ID to resume"),
|
|
234
|
+
message: z.string().describe("Follow-up message text"),
|
|
235
|
+
label: z.string().optional().describe("Override label (defaults to <original>-followup)"),
|
|
236
|
+
supervisor_id: z.string().optional().describe("Reuse an existing supervisor by socket path")
|
|
237
|
+
},
|
|
238
|
+
async execute(args, _context) {
|
|
239
|
+
return followUpTool({
|
|
240
|
+
runId: args.run_id,
|
|
241
|
+
message: args.message,
|
|
242
|
+
label: args.label,
|
|
243
|
+
supervisorId: args.supervisor_id
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
}),
|
|
247
|
+
avenor_events: tool({
|
|
248
|
+
description: "Read events from a run. Filter by type. Returns last N events.",
|
|
249
|
+
args: {
|
|
250
|
+
run_id: z.string().describe("Run ID to read events from"),
|
|
251
|
+
types: z.array(z.string()).optional().describe("Filter by event types"),
|
|
252
|
+
limit: z.number().optional().describe("Max events to return (default 50)"),
|
|
253
|
+
supervisor_id: z.string().optional().describe("Reuse an existing supervisor by socket path")
|
|
254
|
+
},
|
|
255
|
+
async execute(args, _context) {
|
|
256
|
+
return eventsTool({
|
|
257
|
+
runId: args.run_id,
|
|
258
|
+
types: args.types,
|
|
259
|
+
limit: args.limit,
|
|
260
|
+
supervisorId: args.supervisor_id
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
}),
|
|
264
|
+
avenor_shutdown: tool({
|
|
265
|
+
description: "Shut down the avenor supervisor and clean up temp files.",
|
|
266
|
+
args: {
|
|
267
|
+
supervisor_id: z.string().optional().describe("Supervisor to shut down (defaults to singleton)"),
|
|
268
|
+
force: z.boolean().optional().describe("Force shutdown instead of graceful")
|
|
269
|
+
},
|
|
270
|
+
async execute(args, _context) {
|
|
271
|
+
return shutdownTool({
|
|
272
|
+
supervisorId: args.supervisor_id,
|
|
273
|
+
force: args.force
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
})
|
|
105
277
|
}
|
|
106
|
-
}
|
|
107
|
-
}
|
|
278
|
+
};
|
|
279
|
+
};
|
|
108
280
|
//#endregion
|
|
109
281
|
export { AvenorPlugin };
|
package/package.json
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dougbots/avenor-opencode",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"type": "module",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "https://github.com/sdougbrown/avenor",
|
|
8
|
+
"directory": "packages/opencode"
|
|
9
|
+
},
|
|
5
10
|
"files": [
|
|
6
11
|
"dist"
|
|
7
12
|
],
|