opencode-command-hooks 0.1.0 → 0.1.2

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
@@ -1,65 +1,42 @@
1
- # 🪝OpenCode Command Hooks
1
+ # 🪝 OpenCode Command Hooks
2
2
 
3
- Attach shell commands to agent, tool, and session lifecycles using JSON/YAML configuration. Execute commands automatically without consuming tokens or requiring agent interaction.
3
+ Use simple configs to easily integrate shell command hooks with tool/subagent invocations. With a single line of configuration, you can inject a hook's output directly into context for your agent to read.
4
4
 
5
- **Quick Win:** Make your engineer subagent self-validating with 15 lines of config (no TypeScript, no rebuilds, zero tokens):
5
+ Example use cases: run tests after a subagent finishes a task, auto-lint after writes, etc. You can also configure the hooks to run only when specified arguments are passed to a given tool.
6
6
 
7
- ````jsonc
8
- {
9
- "tool": [
10
- {
11
- "id": "auto-validate",
12
- "when": {
13
- "phase": "after",
14
- "tool": "task",
15
- "toolArgs": { "subagent_type": "engineer" },
16
- },
17
- "run": ["npm run typecheck", "npm test"],
18
- "inject": "✅ Validation: {exitCode, select, 0 {Passed} other {Failed - fix errors below}}\n```\n{stdout}\n```",
19
- },
20
- ],
21
- }
22
- ````
23
-
24
- Every time the engineer finishes, tests run automatically and results flow back to the orchestrator. Failed validations trigger self-healing—no manual intervention, no token costs.
25
-
26
- ---
27
-
28
- ## Simplified Agent Markdown Hooks
29
-
30
- Define hooks directly in your agent's markdown file for maximum simplicity. No need for global configs, `id` fields, `when` clauses, or `tool` specifications—everything is auto-configured when you use subagents via the `task` tool.
7
+ ## Markdown Frontmatter Hooks
31
8
 
32
- ### Quick Example
9
+ Define hooks in just a couple lines of markdown frontmatter. Putting them here is also really nice because you can see your entire agent's config in one place.
33
10
 
34
- ```yaml
11
+ ````yaml
35
12
  ---
36
- description: My agent
13
+ description: Analyzes the codebase and implements code changes.
37
14
  mode: subagent
38
15
  hooks:
39
- before:
40
- - run: "echo 'Starting...'"
41
16
  after:
42
- - run: ["npm run typecheck", "npm run lint"]
43
- inject: "Results:\n{stdout}"
44
- - run: "npm test"
45
- toast:
46
- message: "Tests {exitCode, select, 0 {passed} other {failed}}"
17
+ - run: "npm run test"
18
+ inject: "Test Output:\n{stdout}\n{stderr}"
47
19
  ---
48
- # Your agent markdown content here
49
- ```
20
+ ````
50
21
 
51
22
  ### How It Works
52
23
 
53
- 1. **Automatic Targeting**: Hooks defined in agent markdown automatically apply when that subagent is invoked via the `task` tool
54
- 2. **Simplified Syntax**: No `id`, `when`, or `tool` fields needed—everything is inferred from context
55
- 3. **Before/After Hooks**: Use `before` hooks for setup/preparation and `after` hooks for validation/cleanup
56
- 4. **Dual Location Support**: Works with both:
57
- - `.opencode/agent/*.md` (project-level agents)
58
- - `~/.config/opencode/agent/*.md` (user-level agents)
24
+ 1. **Runs automatically** on the configured event
25
+ 2. **Executes shell commands** (sequentially, if you pass an array)
26
+ 3. **Captures output** (truncated to configured limit, default 30,000 characters)
27
+ 4. **Optionally reports results** via `inject` (to the session) and/or `toast` (to the UI).
28
+
29
+ ## Why?
59
30
 
60
- ### Simplified vs Global Config Format
31
+ When working with a fleet of subagents, automatic validation of the state of your codebase is really useful. By setting up quality gates (lint/typecheck/test/etc.) or other automation, you can catch and prevent errors quickly and reliably.
61
32
 
62
- **Global Config (verbose):**
33
+ Doing this by asking your orchestrator agent to use the bash tool (or call a validator subagent) is non-deterministic and can cost a lot of tokens over time. You could always write your own custom plugin to achieve this automatic validation behavior, but I found myself writing the same boilerplate, error handling, output capture, and session injection logic over and over again.
34
+
35
+ Though this plugin is mostly a wrapper around accessing hooks that Opencode already exposes, it provides basic plumbing that reduces overhead, giving you a simple, opinionated system for integrating command hooks into your Opencode workflow. I also just like having hooks/config for my agents all colocated in one place (markdown files) and thought that maybe somebody else would like this too.
36
+
37
+ ---
38
+
39
+ ### JSON Config
63
40
 
64
41
  ```jsonc
65
42
  {
@@ -71,488 +48,282 @@ hooks:
71
48
  "tool": "task",
72
49
  "toolArgs": { "subagent_type": "engineer" },
73
50
  },
74
- "run": ["npm run typecheck", "npm test"],
75
- "inject": "Validation: {exitCode, select, 0 {✓ Passed} other {✗ Failed}}",
76
- "toast": {
77
- "title": "Validation Complete",
78
- "message": "{exitCode, select, 0 {✓ Passed} other {✗ Failed}}",
79
- },
51
+ "run": ["npm run lint", "npm run typecheck", "npm test"],
52
+ "inject": "Validation Results (exit {exitCode}): \n`{stdout}`\n`{stderr}`",
80
53
  },
81
54
  ],
82
55
  }
83
56
  ```
84
57
 
85
- **Agent Markdown (simplified):**
58
+ ### Markdown Frontmatter Config
86
59
 
87
60
  ```yaml
88
61
  hooks:
89
62
  after:
90
- - run: ["npm run typecheck", "npm test"]
91
- inject: "Validation: {exitCode, select, 0 {✓ Passed} other {✗ Failed}}"
63
+ - run: ["npm run lint", "npm run typecheck", "npm test"]
64
+ inject: "Validation Results (exit {exitCode}): \n`{stdout}`\n`{stderr}`"
92
65
  toast:
93
- message: "{exitCode, select, 0 {✓ Passed} other {✗ Failed}}"
66
+ message: "Validation Complete"
94
67
  ```
95
68
 
96
- **Reduction: 60% less boilerplate!**
97
-
98
69
  ### Hook Configuration Options
99
70
 
100
- | Option | Type | Required | Description |
101
- | -------- | ---------------------- | -------- | --------------------------------------------------- |
102
- | `run` | `string` \| `string[]` | ✅ Yes | Command(s) to execute |
103
- | `inject` | `string` | ❌ No | Message to inject into session (supports templates) |
104
- | `toast` | `object` | ❌ No | Toast notification configuration |
71
+ | Option | Type | Description |
72
+ | -------- | ---------------------- | --------------------------------- |
73
+ | `run` | `string` \| `string[]` | Command(s) to execute |
74
+ | `inject` | `string` | Message injected into the session |
75
+ | `toast` | `object` | Toast notification configuration |
105
76
 
106
77
  ### Toast Configuration
107
78
 
108
79
  ```yaml
109
80
  toast:
110
- message: "Build {exitCode, select, 0 {succeeded} other {failed}}"
111
- variant: "{exitCode, select, 0 {success} other {error}}" # info, success, warning, error
112
- duration: 5000 # milliseconds (optional)
81
+ title: "Build" # optional
82
+ message: "exit {exitCode}"
83
+ variant: "info" # optional- one of: info, success, warning, error
84
+ duration: 5000 # optional (milliseconds)
113
85
  ```
114
86
 
115
- ### Template Variables
116
-
117
- Agent markdown hooks support the same template variables as global config:
87
+ ### Inject String Template Variables
118
88
 
119
- - `{stdout}` - Command stdout (truncated to 4000 chars)
120
- - `{stderr}` - Command stderr (truncated to 4000 chars)
121
- - `{exitCode}` - Command exit code
89
+ - `{id}` - Hook ID
90
+ - `{agent}` - Agent name (if available)
91
+ - `{tool}` - Tool name (tool hooks only)
122
92
  - `{cmd}` - Executed command
93
+ - `{stdout}` - Command stdout (truncated)
94
+ - `{stderr}` - Command stderr (truncated)
95
+ - `{exitCode}` - Command exit code
123
96
 
124
97
  ### Complete Example
125
98
 
126
- ````yaml
99
+ ````markdown
127
100
  ---
128
101
  description: Engineer Agent
129
102
  mode: subagent
130
103
  hooks:
131
104
  before:
132
- - run: "echo '🚀 Engineer agent starting...'"
105
+ - run: "echo 'Engineer starting...'"
106
+ toast:
107
+ message: "Engineer starting"
108
+ variant: "info"
133
109
  after:
134
110
  - run: ["npm run typecheck", "npm run lint"]
135
- inject: |
136
- ## Validation Results
137
-
138
- **TypeCheck:** {exitCode, select, 0 {✓ Passed} other {✗ Failed}}
139
-
140
- ```
141
- {stdout}
142
- ```
143
-
144
- {exitCode, select, 0 {} other {⚠️ Please fix validation errors before proceeding.}}
145
- toast:
146
- message: "TypeCheck & Lint: {exitCode, select, 0 {✓ Passed} other {✗ Failed}}"
147
- variant: "{exitCode, select, 0 {success} other {error}}"
148
- - run: "npm test -- --coverage --passWithNoTests"
149
- inject: "Test Coverage: {stdout}%"
150
- toast:
151
- message: "Tests {exitCode, select, 0 {✓ Passed} other {✗ Failed}}"
152
- variant: "{exitCode, select, 0 {success} other {error}}"
111
+ inject: "Typecheck + lint (exit {exitCode}) ``` {stdout} ```"
153
112
  ---
154
- # Engineer Agent
155
- Focus on implementing features with tests and proper error handling.
113
+
114
+ <your subagent instructions>
156
115
  ````
157
116
 
158
117
  ---
159
118
 
160
- ## Why?
161
-
162
- **The Problem:** You want your engineer/validator subagents to automatically run tests/linters and self-heal when validation fails—but asking agents to run validation costs tokens, isn't guaranteed, and requires complex native plugin code with manual error handling.
163
-
164
- **The Solution:** This plugin lets you attach validation commands to subagent completions via simple config. Results automatically inject back to the orchestrator, enabling autonomous quality gates with zero tokens spent.
119
+ ### Automatic Context Injection
165
120
 
166
- ### 1. Zero-Token Automation
167
-
168
- Custom OpenCode plugins require TypeScript setup, manual error handling, and build processes. For routine automation tasks, this creates unnecessary complexity. This plugin provides shell hook functionality through configuration files—no tokens spent prompting agents to run repetitive commands.
169
-
170
- **Native Plugin (Requires TypeScript):**
171
-
172
- ```typescript
173
- // .opencode/plugin/my-plugin.ts
174
- import type { Plugin } from "@opencode-ai/plugin";
121
+ If `inject` is set, the command output is posted into the session, so your agents can react to failures.
175
122
 
176
- export const MyPlugin: Plugin = async ({ $ }) => {
177
- return {
178
- "tool.execute.after": async (input) => {
179
- if (input.tool === "write") {
180
- try {
181
- await $`npm run lint`;
182
- } catch (e) {
183
- console.error(e); // UI spam or crashes
184
- // Agent won't see what failed unless you inject it back
185
- }
186
- }
187
- },
188
- };
189
- };
190
- ```
123
+ ### Filter by Tool Arguments
191
124
 
192
- **This Plugin (Configuration-Based):**
125
+ You can set up tool hooks to only trigger on specific arguments via `when.toolArgs`.
193
126
 
194
127
  ```jsonc
195
128
  {
196
- "tool": [
197
- {
198
- "id": "lint-ts",
199
- "when": { "tool": "write" },
200
- "run": ["npm run lint"],
201
- "inject": {
202
- "as": "system",
203
- "template": "Lint results:\n{stdout}\n{stderr}",
204
- },
205
- },
206
- ],
207
- }
208
- ```
209
-
210
- ### 2. Automatic Context Injection
211
-
212
- Command output returns to the agent automatically without manual SDK calls. The agent can see and react to errors, warnings, or other results:
213
-
214
- ```jsonc
215
- {
216
- "tool": [
217
- {
218
- "id": "typecheck",
219
- "when": { "tool": "write", "toolArgs": { "path": "*.ts" } },
220
- "run": ["npm run typecheck"],
221
- "inject": {
222
- "as": "system",
223
- "template": "TypeScript check:\n{stdout}\n{stderr}",
224
- },
225
- },
226
- ],
227
- }
228
- ```
229
-
230
- For background tasks where the agent doesn't need context, use toast notifications instead:
231
-
232
- ```jsonc
233
- {
234
- "tool": [
235
- {
236
- "id": "build-status",
237
- "when": { "tool": "write", "phase": "after" },
238
- "run": ["npm run build"],
239
- "toast": {
240
- "title": "Build Status",
241
- "message": "Build {exitCode, select, 0 {succeeded} other {failed}}",
242
- "variant": "{exitCode, select, 0 {success} other {error}}",
243
- "duration": 5000,
244
- },
245
- },
246
- ],
247
- }
248
- ```
249
-
250
- ### 3. Filter Tools by Any Argument
251
-
252
- Run different hooks based on any tool argument—not just task subagent types. Filter by file paths, API endpoints, model names, or custom parameters.
253
-
254
- ```jsonc
255
- // Filter by multiple subagent types
256
- {
257
- "when": {
258
- "tool": "task",
259
- "toolArgs": { "subagent_type": ["validator", "reviewer", "tester"] }
260
- }
261
- }
262
-
263
- // Filter write tool by file extension
264
- {
129
+ "id": "playwright-access-localhost",
265
130
  "when": {
266
- "tool": "write",
267
- "toolArgs": { "path": "*.test.ts" }
268
- }
269
- }
270
-
271
- // Filter by API endpoint
272
- {
273
- "when": {
274
- "tool": "fetch",
275
- "toolArgs": { "url": "*/api/validate" }
131
+ "phase": "after",
132
+ "tool": "playwright_browser_navigate",
133
+ "toolArgs": { "url": "http://localhost:3000]" }
134
+ },
135
+ "run": [
136
+ "osascript -e 'display notification \"Agent triggered playwright\"'"
137
+ ],
138
+ "toast": {
139
+ "message": "Agent used the playwright {tool} tool"
276
140
  }
277
141
  }
278
142
  ```
279
143
 
280
- ### 4. Reliable Execution
281
-
282
- - **Non-blocking**: Hook failures don't crash the agent
283
- - **Automatic error handling**: Failed hooks inject error messages automatically
284
- - **Memory safe**: Output truncated to prevent memory issues
285
- - **Sequential execution**: Commands run in order, even if earlier ones fail
286
-
287
- ---
288
-
289
144
  ## Features
290
145
 
291
- - 🔔 **Toast Notifications** - Non-blocking user feedback with customizable titles, messages, and variants
292
- - 🎯 **Precise Filtering** - Filter by tool name, agent, phase, slash command, or ANY tool argument
293
- - 📊 **Complete Template System** - Access to `{id}`, `{agent}`, `{tool}`, `{cmd}`, `{stdout}`, `{stderr}`, `{exitCode}`
294
- - 🔄 **Multiple Event Types** - `tool.execute.before`, `tool.execute.after`, `tool.result`, `session.start`, `session.idle`
295
- - 🧩 **Flexible Configuration** - Global configs in `.opencode/command-hooks.jsonc` or markdown frontmatter
296
- - **Zero-Token Automation** - Guaranteed execution without spending tokens on agent prompts
297
- - 🛡️ **Bulletproof Error Handling** - Graceful failures that never crash the agent
298
- - 🐛 **Debug Mode** - Detailed logging with `OPENCODE_HOOKS_DEBUG=1`
146
+ - Tool hooks (`before`/`after`) and session hooks (`start`/`idle`)
147
+ - Hooks are **non-blocking**: failures don’t crash the session/tool.
148
+ - Commands run **sequentially**, even if earlier ones fail.
149
+ - `inject`/`toast` interpolate using the **last command’s** output if `run` is an array.
150
+ - Match by tool name, calling agent, slash command, and tool arguments
151
+ - Optional session injection and toast notifications
152
+ - Output truncation to keep memory bounded
153
+ - Debug logging via `OPENCODE_HOOKS_DEBUG=1`
299
154
 
300
155
  ---
301
156
 
302
157
  ## Installation
303
158
 
304
- ```bash
305
- opencode install opencode-command-hooks
159
+ Add to your `opencode.json`:
160
+
161
+ ```jsonc
162
+ {
163
+ "plugin": ["opencode-command-hooks"]
164
+ }
306
165
  ```
307
166
 
308
167
  ---
309
168
 
310
169
  ## Configuration
311
170
 
312
- ### Global Configuration
171
+ ### JSON Config
313
172
 
314
- Create `.opencode/command-hooks.jsonc` in your project root:
173
+ Create `.opencode/command-hooks.jsonc` in your project (the plugin searches upward from the current working directory):
315
174
 
316
175
  ```jsonc
317
176
  {
177
+ "truncationLimit": 30000,
318
178
  "tool": [
319
- // Your tool hooks here
179
+ // Tool hooks
320
180
  ],
321
181
  "session": [
322
- // Your session hooks here
182
+ // Session hooks
323
183
  ],
324
184
  }
325
185
  ```
326
186
 
187
+ #### JSON Config Options
188
+
189
+ | Option | Type | Description |
190
+ | ------ | ---- | ----------- |
191
+ | `truncationLimit` | `number` | Maximum characters to capture from command output. Defaults to 30,000 (matching OpenCode's bash tool). Must be a positive integer. |
192
+ | `tool` | `ToolHook[]` | Array of tool execution hooks |
193
+ | `session` | `SessionHook[]`| Array of session lifecycle hooks |
194
+
327
195
  ### Markdown Frontmatter
328
196
 
329
- Override global settings in individual markdown files:
197
+ Use `hooks:` in agent markdown for the simplified format:
330
198
 
331
199
  ```markdown
332
200
  ---
333
- opencode-hooks:
334
- tool:
335
- - id: custom-hook
336
- when: { tool: "write" }
337
- run: ["echo 'File modified'"]
201
+ description: Engineer agent
202
+ mode: subagent
203
+ hooks:
204
+ before:
205
+ - run: "echo 'Starting engineering work...'"
206
+ after:
207
+ - run: "npm run lint"
208
+ inject: "Lint output:\n{stdout}\n{stderr}"
338
209
  ---
339
-
340
- # Your markdown content
341
210
  ```
342
211
 
343
212
  ### Configuration Precedence
344
213
 
345
- 1. **Markdown hooks** override global hooks with the same ID
346
- 2. **Global hooks** are loaded from `.opencode/command-hooks.jsonc`
347
- 3. **Duplicate IDs** within the same source are errors
348
- 4. **Caching** prevents repeated file reads for performance
214
+ 1. Hooks are loaded from `.opencode/command-hooks.jsonc`
215
+ 2. Markdown hooks are converted to normal hooks with auto-generated IDs
216
+ 3. If a markdown hook and a global hook share the same `id`, the markdown hook wins
217
+ 4. Duplicate IDs within the same source are errors
218
+ 5. Global config is cached to avoid repeated file reads
349
219
 
350
220
  ---
351
221
 
352
222
  ## Examples
353
223
 
354
- ### 🚀 Power User Example: Autonomous Quality Gates
224
+ ### Autonomous Quality Gates (After `task`)
355
225
 
356
- This example demonstrates the plugin's killer feature: **automatic validation injection after subagent work**. Unlike native plugins that require complex TypeScript and manual result handling, this achieves enterprise-grade quality gates with pure configuration.
226
+ Run validation after certain subagents complete, inject results back into the session, and show a small toast.
357
227
 
358
- ````jsonc
228
+ ```jsonc
359
229
  {
360
230
  "tool": [
361
231
  {
362
- "id": "validate-engineer-work",
232
+ "id": "validate-after-task",
363
233
  "when": {
364
234
  "phase": "after",
365
235
  "tool": "task",
366
236
  "toolArgs": { "subagent_type": ["engineer", "debugger"] },
367
237
  },
368
- "run": [
369
- "npm run typecheck",
370
- "npm run lint",
371
- "npm test -- --coverage --passWithNoTests",
372
- ],
373
- "inject": "🔍 Validation Results:\n\n**TypeCheck:** {exitCode, select, 0 {✓ Passed} other {✗ Failed}}\n\n```\n{stdout}\n```\n\n{exitCode, select, 0 {} other {⚠️ The code you just wrote has validation errors. Please fix them before proceeding.}}",
238
+ "run": ["npm run typecheck", "npm run lint", "npm test"],
239
+ "inject": "Validation (exit {exitCode})\n\n{stdout}\n{stderr}",
374
240
  "toast": {
375
- "title": "Code Validation",
376
- "message": "{exitCode, select, 0 {All checks passed ✓} other {Validation failed - check errors}}",
377
- "variant": "{exitCode, select, 0 {success} other {error}}",
241
+ "title": "Validation",
242
+ "message": "exit {exitCode}",
243
+ "variant": "info",
378
244
  "duration": 5000,
379
245
  },
380
246
  },
381
- {
382
- "id": "verify-test-coverage",
383
- "when": {
384
- "phase": "after",
385
- "tool": "task",
386
- "toolArgs": { "subagent_type": "engineer" },
387
- },
388
- "run": [
389
- "npm test -- --coverage --json > coverage.json && node -p 'JSON.parse(require(\"fs\").readFileSync(\"coverage.json\")).coverageMap.total.lines.pct'",
390
- ],
391
- "inject": "📊 Test Coverage: {stdout}%\n\n{stdout, select, ^[89]\\d|100$ {} other {⚠️ Coverage is below 80%. Please add more tests.}}",
392
- },
393
247
  ],
394
248
  }
395
- ````
396
-
397
- **What makes this powerful:**
398
-
399
- 1. **Zero-Token Enforcement** - Quality gates run automatically after engineer/debugger subagents without consuming tokens to prompt validation
400
- 2. **Intelligent Filtering** - Uses `toolArgs.subagent_type` to target specific subagents (impossible with native plugins without SDK access)
401
- 3. **Context Injection** - Validation results automatically flow back to the orchestrator/agent, enabling self-healing workflows
402
- 4. **Non-Blocking** - Failed validations don't crash the session; the agent sees errors and can fix them
403
- 5. **Dual Feedback** - Users get instant toast notifications while agents receive detailed error context
404
- 6. **Sequential Commands** - Multiple validation steps run in order, even if earlier ones fail
405
- 7. **Template Power** - Conditional messages using ICU MessageFormat syntax (`{exitCode, select, ...}`)
406
-
407
- **Real-world impact:** A single hook configuration replaces 50+ lines of TypeScript plugin code with error handling, session.prompt calls, and manual filtering logic. The agent becomes self-validating without you spending a single token to ask it to run tests.
408
-
409
- ---
410
-
411
- ### Basic Examples
249
+ ```
412
250
 
413
- #### Auto-Verify Subagent Work
251
+ ### Run Tests After Any `task` (subagent creation toolcall)
414
252
 
415
- ````jsonc
253
+ ```jsonc
416
254
  {
417
255
  "tool": [
418
256
  {
419
- "id": "verify-subagent-work",
420
- "when": {
421
- "phase": "after",
422
- "tool": ["task"],
423
- },
257
+ "id": "tests-after-task",
258
+ "when": { "phase": "after", "tool": "task" },
424
259
  "run": ["npm test"],
425
- "inject": "Test Runner:\nExit Code: {exitCode}\n\nOutput:\n```\n{stdout}\n```\n\nIf tests failed, please fix them before proceeding.",
260
+ "inject": "Tests (exit {exitCode})\n\n{stdout}\n{stderr}",
426
261
  },
427
262
  ],
428
263
  }
429
- ````
430
-
431
- #### Multi-Stage Validation Pipeline
264
+ ```
432
265
 
433
- ````jsonc
434
- {
435
- "tool": [
436
- {
437
- "id": "validator-security-scan",
438
- "when": {
439
- "phase": "after",
440
- "tool": "task",
441
- "toolArgs": { "subagent_type": "validator" },
442
- },
443
- "run": [
444
- "npm audit --audit-level=moderate",
445
- "git diff --name-only | xargs grep -l 'API_KEY\\|SECRET\\|PASSWORD' || true",
446
- ],
447
- "inject": "🔒 Security Scan:\n\n**Audit:** {exitCode, select, 0 {No vulnerabilities} other {⚠️ Vulnerabilities found}}\n\n```\n{stdout}\n```\n\nPlease address security issues before deployment.",
448
- },
449
- ],
450
- }
451
- ````
266
+ ### Enforce Linting After a Specific `write`
452
267
 
453
- #### Enforce Linting on File Edits
268
+ Tool-arg matching is exact. This example runs only when the tool arg `path` equals `src/index.ts`.
454
269
 
455
270
  ```jsonc
456
271
  {
457
272
  "tool": [
458
273
  {
459
- "id": "lint-on-save",
274
+ "id": "lint-src-index",
460
275
  "when": {
461
276
  "phase": "after",
462
- "tool": ["write"],
277
+ "tool": "write",
278
+ "toolArgs": { "path": "src/index.ts" },
463
279
  },
464
- "run": ["npm run lint -- --fix"],
465
- "inject": "Linting auto-fix results: {stdout}",
280
+ "run": ["npm run lint"],
281
+ "inject": "Lint (exit {exitCode})\n\n{stdout}\n{stderr}",
466
282
  },
467
283
  ],
468
284
  }
469
285
  ```
470
286
 
471
- ### Advanced Examples
472
-
473
- #### Toast Notifications for Build Status
287
+ ### Toast Notifications for Build Status
474
288
 
475
- ````jsonc
289
+ ```jsonc
476
290
  {
477
291
  "tool": [
478
292
  {
479
- "id": "build-notification",
480
- "when": {
481
- "phase": "after",
482
- "tool": ["write"],
483
- "toolArgs": { "path": "*.ts" },
484
- },
293
+ "id": "build-toast",
294
+ "when": { "phase": "after", "tool": "write" },
485
295
  "run": ["npm run build"],
486
296
  "toast": {
487
- "title": "TypeScript Build",
488
- "message": "Build {exitCode, select, 0 {succeeded ✓} other {failed ✗}}",
489
- "variant": "{exitCode, select, 0 {success} other {error}}",
297
+ "title": "Build",
298
+ "message": "exit {exitCode}",
299
+ "variant": "info",
490
300
  "duration": 3000,
491
301
  },
492
- "inject": {
493
- "as": "system",
494
- "template": "Build output:\n```\n{stdout}\n```",
495
- },
496
- },
497
- ],
498
- }
499
- ````
500
-
501
- #### Filter by Multiple Tool Arguments
502
-
503
- ```jsonc
504
- {
505
- "tool": [
506
- // Run for specific file types
507
- {
508
- "id": "test-js-files",
509
- "when": {
510
- "tool": "write",
511
- "toolArgs": {
512
- "path": ["*.js", "*.jsx", "*.ts", "*.tsx"],
513
- },
514
- },
515
- "run": ["npm test -- --testPathPattern={toolArgs.path}"],
516
- },
517
- // Filter by multiple subagent types
518
- {
519
- "id": "validate-subagents",
520
- "when": {
521
- "tool": "task",
522
- "toolArgs": {
523
- "subagent_type": ["validator", "reviewer", "tester"],
524
- },
525
- },
526
- "run": ["echo 'Validating {toolArgs.subagent_type} subagent'"],
527
302
  },
528
303
  ],
529
304
  }
530
305
  ```
531
306
 
532
- #### Handle Async Tool Completion
307
+ ### Handle Async Tool Completion (`tool.result`)
533
308
 
534
309
  ```jsonc
535
310
  {
536
311
  "tool": [
537
312
  {
538
- "id": "task-complete",
313
+ "id": "task-result-hook",
539
314
  "when": {
540
- "event": "tool.result",
541
- "tool": ["task"],
315
+ "phase": "after",
316
+ "tool": "task",
542
317
  "toolArgs": { "subagent_type": "code-writer" },
543
318
  },
544
319
  "run": ["npm run validate-changes"],
545
- "toast": {
546
- "title": "Code Writer Complete",
547
- "message": "Validation: {exitCode, select, 0 {Passed} other {Failed}}",
548
- "variant": "{exitCode, select, 0 {success} other {warning}}",
549
- },
320
+ "toast": { "title": "Code Writer", "message": "exit {exitCode}" },
550
321
  },
551
322
  ],
552
323
  }
553
324
  ```
554
325
 
555
- #### Session Lifecycle Hooks
326
+ ### Session Lifecycle Hooks
556
327
 
557
328
  ```jsonc
558
329
  {
@@ -561,11 +332,7 @@ This example demonstrates the plugin's killer feature: **automatic validation in
561
332
  "id": "session-start",
562
333
  "when": { "event": "session.start" },
563
334
  "run": ["echo 'New session started'"],
564
- "toast": {
565
- "title": "Session Started",
566
- "message": "Ready to assist!",
567
- "variant": "info",
568
- },
335
+ "toast": { "title": "Session", "message": "started", "variant": "info" },
569
336
  },
570
337
  {
571
338
  "id": "session-idle",
@@ -580,217 +347,95 @@ This example demonstrates the plugin's killer feature: **automatic validation in
580
347
 
581
348
  ## Template Placeholders
582
349
 
583
- All templates support these placeholders:
584
-
585
- | Placeholder | Description | Example |
586
- | ------------ | ---------------------------------------- | -------------------------- |
587
- | `{id}` | Hook ID | `lint-ts` |
588
- | `{agent}` | Calling agent name | `orchestrator` |
589
- | `{tool}` | Tool name | `write` |
590
- | `{cmd}` | Executed command | `npm run lint` |
591
- | `{stdout}` | Command stdout (truncated to 4000 chars) | `Linting complete` |
592
- | `{stderr}` | Command stderr (truncated to 4000 chars) | `Error: missing semicolon` |
593
- | `{exitCode}` | Command exit code | `0` or `1` |
594
-
595
- **Advanced Usage:**
596
-
597
- ```jsonc
598
- {
599
- "inject": {
600
- "template": "Command '{cmd}' exited with code {exitCode}\n\nStdout:\n{stdout}\n\nStderr:\n{stderr}",
601
- },
602
- }
603
- ```
604
-
605
- ---
606
-
607
- ## Debugging
608
-
609
- Enable detailed debug logging:
610
-
611
- ```bash
612
- export OPENCODE_HOOKS_DEBUG=1
613
- opencode start
614
- ```
615
-
616
- This logs:
617
-
618
- - Command execution details
619
- - Template interpolation
620
- - Hook matching logic
621
- - Error handling
622
-
623
- **Example Debug Output:**
624
-
625
- ```
626
- [DEBUG] Hook matched: lint-ts
627
- [DEBUG] Executing: npm run lint
628
- [DEBUG] Template interpolated: Exit code: 0
629
- [DEBUG] Toast shown: Lint Complete
630
- ```
631
-
632
- ---
633
-
634
- ## Event Types
350
+ All inject/toast string templates support these placeholders:
635
351
 
636
- ### Tool Events
637
-
638
- - **`tool.execute.before`** - Before tool execution
639
- - **`tool.execute.after`** - After tool execution
640
- - **`tool.result`** - When async tools complete (task, firecrawl, etc.)
641
-
642
- ### Session Events
643
-
644
- - **`session.start`** - New session started
645
- - **`session.idle`** - Session became idle
646
- - **`session.end`** - Session ended
352
+ | Placeholder | Description | Example |
353
+ | ------------ | -------------------------- | -------------------------- |
354
+ | `{id}` | Hook ID | `lint-ts` |
355
+ | `{agent}` | Calling agent name | `orchestrator` |
356
+ | `{tool}` | Tool name | `write` |
357
+ | `{cmd}` | Executed command | `npm run lint` |
358
+ | `{stdout}` | Command stdout (truncated) | `Linting complete` |
359
+ | `{stderr}` | Command stderr (truncated) | `Error: missing semicolon` |
360
+ | `{exitCode}` | Command exit code | `0` or `1` |
647
361
 
648
362
  ---
649
363
 
650
- ## Tool vs Session Hooks
651
-
652
- ### Tool Hooks
653
-
654
- - Run before/after specific tool executions
655
- - Can filter by tool name, calling agent, slash command, tool arguments
656
- - Access to tool-specific context (tool name, call ID, args)
657
- - **Best for**: Linting after writes, tests after code changes, validation
658
-
659
- ### Session Hooks
660
-
661
- - Run on session lifecycle events
662
- - Can only filter by agent name (session events lack tool context)
663
- - **Best for**: Bootstrapping, cleanup, periodic checks
664
-
665
- ---
364
+ ## Why Use This Plugin?
365
+ **It lets you easily set up bash hooks with ~3-5 lines of YAML which are cleanly colocated with your subagent configuration.**
366
+ Conversely, rolling your own looks something like this (for each project and set of hooks you want to set up):
367
+ ```ts
368
+ import type { Plugin } from "@opencode-ai/plugin";
666
369
 
667
- ## Native Plugin vs This Plugin
370
+ export const MyHooks: Plugin = async ({ $, client }) => {
371
+ const argsCache = new Map();
668
372
 
669
- **The Real Difference:** The Power User Example above would require this native plugin implementation:
373
+ return {
374
+ "tool.execute.before": async (input, output) => {
375
+ if (input.tool === "task") {
376
+ argsCache.set(input.callID, output.args);
377
+ }
378
+ },
670
379
 
671
- **Native Plugin: 73 lines of TypeScript with manual everything**
380
+ "tool.execute.after": async (input, output) => {
381
+ if (!output && input.tool === "task") return;
672
382
 
673
- ```typescript
674
- import type { Plugin } from "@opencode-ai/plugin";
383
+ const args = argsCache.get(input.callID);
384
+ argsCache.delete(input.callID);
675
385
 
676
- export const ValidationPlugin: Plugin = async ({ $, client }) => {
677
- return {
678
- "tool.execute.after": async (input) => {
386
+ // Filter by tool and subagent type
679
387
  if (input.tool !== "task") return;
680
-
681
- // No way to filter by toolArgs.subagent_type without complex parsing
388
+ if (!["engineer", "debugger"].includes(args?.subagent_type)) return;
682
389
 
683
390
  try {
684
- const results: string[] = [];
685
-
686
- try {
687
- const typecheck = await $`npm run typecheck`.text();
688
- results.push(`TypeCheck: ${typecheck}`);
689
- } catch (e: any) {
690
- results.push(`TypeCheck failed: ${e.stderr || e.message}`);
391
+ // Run commands sequentially, even if they fail
392
+ let lastResult = { exitCode: 0, stdout: "", stderr: "" };
393
+
394
+ for (const cmd of ["npm run typecheck", "npm run lint"]) {
395
+ try {
396
+ const result = await $`sh -c ${cmd}`.nothrow().quiet();
397
+ const stdout = result.stdout?.toString() || "";
398
+ const stderr = result.stderr?.toString() || "";
399
+
400
+ // Truncate to 30k chars to match OpenCode's bash tool
401
+ lastResult = {
402
+ exitCode: result.exitCode ?? 0,
403
+ stdout: stdout.length > 30000
404
+ ? stdout.slice(0, 30000) + "\n[Output truncated: exceeded 30000 character limit]"
405
+ : stdout,
406
+ stderr: stderr.length > 30000
407
+ ? stderr.slice(0, 30000) + "\n[Output truncated: exceeded 30000 character limit]"
408
+ : stderr,
409
+ };
410
+ } catch (err) {
411
+ lastResult = { exitCode: 1, stdout: "", stderr: String(err) };
412
+ }
691
413
  }
692
414
 
693
- try {
694
- const lint = await $`npm run lint`.text();
695
- results.push(`Lint: ${lint}`);
696
- } catch (e: any) {
697
- results.push(`Lint failed: ${e.stderr || e.message}`);
698
- }
699
-
700
- try {
701
- const test = await $`npm test -- --coverage --passWithNoTests`.text();
702
- results.push(`Tests: ${test}`);
703
- } catch (e: any) {
704
- results.push(`Tests failed: ${e.stderr || e.message}`);
705
- }
706
-
707
- const output = results.join("\n\n");
708
- const exitCode = results.some((r) => r.includes("failed")) ? 1 : 0;
709
- const message = `🔍 Validation Results:\n\n${output}\n\n${
710
- exitCode !== 0
711
- ? "⚠️ The code you just wrote has validation errors. Please fix them before proceeding."
712
- : ""
713
- }`;
714
-
715
- await client.session.prompt({
716
- sessionID: input.sessionID,
717
- message,
415
+ // Inject results into session (noReply prevents LLM response)
416
+ const message = `Validation (exit ${lastResult.exitCode})\n\n${lastResult.stdout}\n${lastResult.stderr}`;
417
+ await client.session.promptAsync({
418
+ path: { id: input.sessionID },
419
+ body: {
420
+ noReply: true,
421
+ parts: [{ type: "text", text: message }],
422
+ },
718
423
  });
719
424
 
720
- console.log(
721
- exitCode === 0 ? "✓ All checks passed" : "✗ Validation failed",
722
- );
723
- } catch (e) {
724
- console.error("Validation hook failed:", e);
425
+ // Show toast notification
426
+ await client.tui.showToast({
427
+ body: {
428
+ title: "Validation",
429
+ message: `exit ${lastResult.exitCode}`,
430
+ variant: "info",
431
+ },
432
+ });
433
+ } catch (err) {
434
+ console.error("Hook failed:", err);
725
435
  }
726
436
  },
727
437
  };
728
438
  };
729
439
  ```
730
440
 
731
- Problems: Can't filter by subagent_type, manual error handling for each command, manual template building, manual session.prompt calls, no toast notifications, must rebuild after changes.
732
-
733
- **This Plugin: 15 lines of JSON**
734
-
735
- ````jsonc
736
- {
737
- "id": "validate-engineer-work",
738
- "when": {
739
- "phase": "after",
740
- "tool": "task",
741
- "toolArgs": { "subagent_type": ["engineer", "debugger"] },
742
- },
743
- "run": [
744
- "npm run typecheck",
745
- "npm run lint",
746
- "npm test -- --coverage --passWithNoTests",
747
- ],
748
- "inject": "🔍 Validation Results:\n\n**TypeCheck:** {exitCode, select, 0 {✓ Passed} other {✗ Failed}}\n\n```\n{stdout}\n```\n\n{exitCode, select, 0 {} other {⚠️ Please fix validation errors.}}",
749
- "toast": {
750
- "title": "Code Validation",
751
- "message": "{exitCode, select, 0 {All checks passed ✓} other {Validation failed}}",
752
- "variant": "{exitCode, select, 0 {success} other {error}}",
753
- },
754
- }
755
- ````
756
-
757
- ---
758
-
759
- ### Feature Comparison
760
-
761
- | Feature | Native Plugin | This Plugin |
762
- | ------------------------ | --------------------------------------- | ---------------------------- |
763
- | **Setup** | TypeScript, build steps, error handling | JSON/YAML config |
764
- | **Error Handling** | Manual try/catch required | Automatic, non-blocking |
765
- | **User Feedback** | Console logs (UI spam) | Toast notifications |
766
- | **Context Injection** | Manual SDK calls | Automatic |
767
- | **Tool Filtering** | Basic tool name only | Tool name + ANY arguments |
768
- | **Subagent Targeting** | Complex parsing required | Native `toolArgs` filter |
769
- | **Guaranteed Execution** | Depends on agent | Always runs |
770
- | **Token Cost** | Variable | Zero tokens |
771
- | **Hot Reload** | Requires rebuild | Edit config, works instantly |
772
- | **Debugging** | Console.log | OPENCODE_HOOKS_DEBUG=1 |
773
-
774
- ---
775
-
776
- ## Known Limitations
777
-
778
- ### Session Hooks Cannot Filter by Agent
779
-
780
- Session lifecycle events (`session.start`, `session.idle`, `session.end`) don't include the calling agent name. You **cannot** use the `agent` filter field in session hook conditions—it matches all agents.
781
-
782
- **Workaround:** Use `tool.execute.after` events instead, which provide agent context.
783
-
784
- ---
785
-
786
- ## Development
787
-
788
- ```bash
789
- bun install
790
- bun run build
791
- ```
792
-
793
- TODO:
794
-
795
- - Implement max-length output using tail
796
- - Add more template functions (date formatting, etc.)
441
+ The plugin handles: sequential execution with failure recovery, output truncation, exit code capture, template interpolation, session injection, toast notifications, toolArgs filtering, async tool support, and non-blocking error handling.