@dougbots/avenor-opencode 0.1.0 → 0.2.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.
Files changed (3) hide show
  1. package/README.md +31 -8
  2. package/dist/index.js +271 -99
  3. package/package.json +7 -2
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. Returns a `run_id` you can poll with `avenor_status`.
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 workflow
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 → receive run_id
111
- 2. avenor_status → check if still running, spot pending permissions
112
- 3. avenor_answer_permission → unblock if a permission is requested
113
- 4. avenor_events → inspect output once complete
114
- 5. avenor_follow_up → optionally send a follow-up
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 AvenorPlugin = async (_ctx) => ({ tool: {
6
- avenor_spawn: tool({
7
- description: "Dispatch an agent run via avenor. Returns run_id for status polling.",
8
- args: {
9
- agent: z.string().describe("Agent name (required, no default)"),
10
- prompt: z.string().optional().describe("Prompt text"),
11
- prompt_file: z.string().optional().describe("Path to prompt file"),
12
- label: z.string().optional().describe("Human-readable label for the run"),
13
- timeout: z.string().optional().describe("Timeout duration (e.g. 3600s)"),
14
- model: z.string().optional().describe("Model override"),
15
- supervisor_id: z.string().optional().describe("Reuse an existing supervisor by socket path")
16
- },
17
- async execute(args, context) {
18
- return spawnTool({
19
- agent: args.agent,
20
- prompt: args.prompt,
21
- promptFile: args.prompt_file,
22
- label: args.label,
23
- dir: context.directory,
24
- timeout: args.timeout,
25
- model: args.model,
26
- supervisorId: args.supervisor_id
27
- });
28
- }
29
- }),
30
- avenor_status: tool({
31
- description: "Get status of a run or all runs. Surfaces pending permission requests.",
32
- args: {
33
- run_id: z.string().optional().describe("Specific run ID to query"),
34
- supervisor_id: z.string().optional().describe("Reuse an existing supervisor by socket path")
35
- },
36
- async execute(args, _context) {
37
- return statusTool({
38
- runId: args.run_id,
39
- supervisorId: args.supervisor_id
40
- });
41
- }
42
- }),
43
- avenor_answer_permission: tool({
44
- description: "Answer a pending permission request. Pass option_id from avenor_status pending_permission.options.",
45
- args: {
46
- run_id: z.string().describe("Run ID with the pending permission"),
47
- option_id: z.string().describe("Option ID from pending_permission.options array"),
48
- request_id: z.string().optional().describe("Request ID (auto-discovered if omitted)"),
49
- supervisor_id: z.string().optional().describe("Reuse an existing supervisor by socket path")
50
- },
51
- async execute(args, _context) {
52
- return answerPermissionTool({
53
- runId: args.run_id,
54
- optionId: args.option_id,
55
- requestId: args.request_id,
56
- supervisorId: args.supervisor_id
57
- });
58
- }
59
- }),
60
- avenor_follow_up: tool({
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
- avenor_events: tool({
78
- description: "Read events from a run. Filter by type. Returns last N events.",
79
- args: {
80
- run_id: z.string().describe("Run ID to read events from"),
81
- types: z.array(z.string()).optional().describe("Filter by event types"),
82
- limit: z.number().optional().describe("Max events to return (default 50)"),
83
- supervisor_id: z.string().optional().describe("Reuse an existing supervisor by socket path")
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 execute(args, _context) {
86
- return eventsTool({
87
- runId: args.run_id,
88
- types: args.types,
89
- limit: args.limit,
90
- supervisorId: args.supervisor_id
91
- });
92
- }
93
- }),
94
- avenor_shutdown: tool({
95
- description: "Shut down the avenor supervisor and clean up temp files.",
96
- args: {
97
- supervisor_id: z.string().optional().describe("Supervisor to shut down (defaults to singleton)"),
98
- force: z.boolean().optional().describe("Force shutdown instead of graceful")
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
- async execute(args, _context) {
101
- return shutdownTool({
102
- supervisorId: args.supervisor_id,
103
- force: args.force
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.1.0",
3
+ "version": "0.2.1",
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
  ],
@@ -22,7 +27,7 @@
22
27
  "dev": "tsdown --watch"
23
28
  },
24
29
  "dependencies": {
25
- "@dougbots/avenor-core": "workspace:*",
30
+ "@dougbots/avenor-core": "^0.2.1",
26
31
  "zod": "^4.4.3"
27
32
  },
28
33
  "peerDependencies": {