opencode-command-hooks 0.1.1 → 0.1.3

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