@jacob-z/oxlint-gate 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,127 @@
1
+ # oxlint-gate
2
+
3
+ Real-time type assertion gate for [oh-my-pi](https://github.com/JacobZyy/oh-my-pi).
4
+
5
+ Intercepts Edit/Write tool calls and checks the target file with oxlint before allowing the edit to proceed. Blocks if type laziness assertions (e.g., `as any`, `as unknown as X`) are detected.
6
+
7
+ ## Features
8
+
9
+ - **Real-time blocking**: Checks files before they're saved, not after
10
+ - **oxlint integration**: Uses the same rules as your CLI workflow
11
+ - **Configurable**: Reads ignore patterns from `~/.config/oxlint/oxlintrc.json`
12
+ - **Fail-open**: If oxlint is not installed or crashes, edits are allowed (won't block your workflow)
13
+ - **Local logs**: Writes detailed logs to `~/.omp/logs/oxlint-gate.log` for debugging
14
+
15
+ ## Prerequisites
16
+
17
+ 1. **oxlint** installed globally:
18
+
19
+ ```bash
20
+ npm install -g oxlint
21
+ ```
22
+
23
+ 2. **oxlint config** at `~/.config/oxlint/oxlintrc.json`:
24
+ ```json
25
+ {
26
+ "rules": {
27
+ "typescript/no-explicit-any": "error",
28
+ "typescript/no-unnecessary-type-assertion": "error"
29
+ },
30
+ "ignorePatterns": ["*.test.ts", "*.config.ts"]
31
+ }
32
+ ```
33
+
34
+ ## Installation
35
+
36
+ ### Via OMP marketplace
37
+
38
+ ```bash
39
+ omp plugin install @jacob-z/oxlint-gate
40
+ ```
41
+
42
+ ### Manual installation
43
+
44
+ 1. Clone this repository
45
+ 2. Link the package:
46
+ ```bash
47
+ cd packages/oxlint-gate
48
+ bun link
49
+ ```
50
+ 3. Add to your OMP config (`~/.omp/agent/config.yml`):
51
+ ```yaml
52
+ extensions:
53
+ - /path/to/jacob-z/packages/oxlint-gate/src/index.ts
54
+ ```
55
+
56
+ ## How it works
57
+
58
+ 1. When you use Edit/Write tools in OMP, the extension intercepts the tool call
59
+ 2. It extracts the target file path from the tool input
60
+ 3. If the file is a TypeScript/Vue file, it runs oxlint with your config
61
+ 4. If type assertion violations are found, the edit is blocked with a detailed error message
62
+ 5. The error message includes the violations and suggests how to fix them
63
+
64
+ ## Configuration
65
+
66
+ The extension reads from `~/.config/oxlint/oxlintrc.json`:
67
+
68
+ - `rules`: oxlint rules to check
69
+ - `ignorePatterns`: glob patterns for files to skip (e.g., `*.test.ts`, `*.config.ts`)
70
+
71
+ ## Logs
72
+
73
+ Logs are written to `~/.omp/logs/oxlint-gate.log`.
74
+
75
+ ```bash
76
+ # View logs in real-time
77
+ tail -f ~/.omp/logs/oxlint-gate.log
78
+
79
+ # Search for blocked edits
80
+ grep "BLOCKED" ~/.omp/logs/oxlint-gate.log
81
+
82
+ # View today's checks
83
+ grep "$(date +%Y-%m-%d)" ~/.omp/logs/oxlint-gate.log
84
+ ```
85
+
86
+ Log format:
87
+
88
+ ```
89
+ [2026-05-30T10:15:30.123Z] [INFO] extension loaded
90
+ [2026-05-30T10:15:35.456Z] [INFO] checking: /path/to/file.ts
91
+ [2026-05-30T10:15:35.789Z] [WARN] BLOCKED: /path/to/file.ts
92
+ Found 2 errors.
93
+ ...
94
+ [2026-05-30T10:16:00.123Z] [INFO] passed: /path/to/other.ts
95
+ ```
96
+
97
+ ## Error message format
98
+
99
+ When violations are found, you'll see:
100
+
101
+ ```
102
+ ❌ [oxlint-gate] 检测到类型偷懒断言 — Found 2 errors.
103
+
104
+ /path/to/file.ts
105
+ 10:5 error Unexpected any, use a specific type @typescript-eslint/no-explicit-any
106
+ 15:10 error Unnecessary type assertion @typescript-eslint/no-unnecessary-type-assertion
107
+
108
+ 按 ts-type-discipline 协议处理:
109
+ 1) 优先用泛型 / 条件类型 / 类型守卫消除断言,禁止 as any / as unknown as X
110
+ 2) 类型体操无效 → 追溯并修复底层类型声明(接口/DTO/类型定义)
111
+ 3) 若是后端接口少返回字段 → 用 AskUserQuestion 与用户确认方案
112
+ ```
113
+
114
+ ## Differences from Claude Code hook
115
+
116
+ This is an OMP extension ported from the Claude Code hook in [jacob-skills-collection](https://github.com/JacobZyy/jacob-skills-collection).
117
+
118
+ | Feature | Claude Code hook | OMP extension |
119
+ | ----------- | ---------------------- | --------------------------- |
120
+ | Timing | End of session | Real-time (before save) |
121
+ | Blocking | Blocks session | Blocks tool call |
122
+ | Transcript | Reads JSONL transcript | Intercepts tool_call events |
123
+ | Performance | Batch check at end | Single file check per edit |
124
+
125
+ ## License
126
+
127
+ MIT
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@jacob-z/oxlint-gate",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "description": "Real-time type assertion gate using oxlint — blocks `as any` and other type laziness before files are saved",
6
+ "license": "MIT",
7
+ "author": { "name": "Jacob" },
8
+ "repository": { "type": "git", "url": "https://github.com/JacobZyy/jacob-omp-collections", "directory": "packages/oxlint-gate" },
9
+ "omp": {
10
+ "extensions": ["./src/index.ts"]
11
+ },
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/index.d.ts",
15
+ "import": "./src/index.ts"
16
+ }
17
+ },
18
+ "main": "./src/index.ts",
19
+ "files": ["src"],
20
+ "scripts": {
21
+ "build": "tsc --build",
22
+ "test": "vitest run",
23
+ "test:watch": "vitest",
24
+ "typecheck": "tsc --noEmit"
25
+ },
26
+ "devDependencies": {
27
+ "@types/node": "^25.9.1"
28
+ }
29
+ }
@@ -0,0 +1,39 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { extractFilePath } from './index'
3
+
4
+ describe('extractFilePath', () => {
5
+ it('should extract path from direct path field', () => {
6
+ const input = { path: '/foo/bar.ts' }
7
+ expect(extractFilePath(input)).toBe('/foo/bar.ts')
8
+ })
9
+
10
+ it('should extract path from hashline input', () => {
11
+ const input = { input: '¶/foo/bar.ts#abc123\nreplace 1..1:\n+new line' }
12
+ expect(extractFilePath(input)).toBe('/foo/bar.ts')
13
+ })
14
+
15
+ it('should extract path from apply-patch input', () => {
16
+ const input = { input: '*** Add File: /foo/bar.ts\n+content' }
17
+ expect(extractFilePath(input)).toBe('/foo/bar.ts')
18
+ })
19
+
20
+ it('should return undefined for missing path', () => {
21
+ const input = { content: 'some content' }
22
+ expect(extractFilePath(input)).toBeUndefined()
23
+ })
24
+
25
+ it('should return undefined for empty path', () => {
26
+ const input = { path: '' }
27
+ expect(extractFilePath(input)).toBeUndefined()
28
+ })
29
+
30
+ it('should handle Update File in apply-patch', () => {
31
+ const input = { input: '*** Update File: /foo/bar.ts\n-old\n+new' }
32
+ expect(extractFilePath(input)).toBe('/foo/bar.ts')
33
+ })
34
+
35
+ it('should handle Delete File in apply-patch', () => {
36
+ const input = { input: '*** Delete File: /foo/bar.ts' }
37
+ expect(extractFilePath(input)).toBe('/foo/bar.ts')
38
+ })
39
+ })
package/src/index.ts ADDED
@@ -0,0 +1,311 @@
1
+ /**
2
+ * oxlint-gate: Real-time type assertion gate for OMP.
3
+ *
4
+ * Intercepts Edit/Write tool calls and checks the target file with oxlint
5
+ * before allowing the edit to proceed. Blocks if type laziness assertions
6
+ * (e.g., `as any`, `as unknown as X`) are detected.
7
+ *
8
+ * Configuration: reads rules from `~/.config/oxlint/oxlintrc.json`
9
+ * Logs: writes to `~/.omp/logs/oxlint-gate.log`
10
+ */
11
+
12
+ import type { ExtensionAPI, ExtensionFactory } from './omp-types'
13
+ import { spawnSync } from 'node:child_process'
14
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync } from 'node:fs'
15
+ import { homedir } from 'node:os'
16
+ import { isAbsolute, join, relative, resolve } from 'node:path'
17
+ import process from 'node:process'
18
+
19
+ const TS_EXTENSIONS = /\.(?:ts|tsx|mts|cts|vue)$/
20
+ const OXLINT_CFG = join(homedir(), '.config', 'oxlint', 'oxlintrc.json')
21
+ const LOG_DIR = join(homedir(), '.omp', 'logs')
22
+ const LOG_FILE = join(LOG_DIR, 'oxlint-gate.log')
23
+ const HOME = homedir()
24
+
25
+ // Tools that modify files
26
+ const WRITE_TOOLS = new Set(['edit', 'write'])
27
+
28
+ /** Max auto-fix attempts per file per turn. */
29
+ const MAX_FIX_ATTEMPTS = 3
30
+
31
+ /** Max lines of oxlint output to keep. */
32
+ const MAX_OUTPUT_LINES = 20
33
+
34
+ // ── Types ──────────────────────────────────────────────────────────────────
35
+
36
+ interface OxlintConfig {
37
+ ignorePatterns?: string[]
38
+ }
39
+
40
+ // ── Local Logger ───────────────────────────────────────────────────────────
41
+
42
+ function ensureLogDir(): void {
43
+ if (!existsSync(LOG_DIR)) {
44
+ mkdirSync(LOG_DIR, { recursive: true })
45
+ }
46
+ }
47
+
48
+ function writeLog(level: 'INFO' | 'WARN' | 'DEBUG', msg: string): void {
49
+ try {
50
+ ensureLogDir()
51
+ const ts = new Date().toISOString()
52
+ appendFileSync(LOG_FILE, `[${ts}] [${level}] ${msg}\n`)
53
+ }
54
+ catch {
55
+ // Silently ignore log write failures
56
+ }
57
+ }
58
+
59
+ // ── Helpers ────────────────────────────────────────────────────────────────
60
+
61
+ /**
62
+ * Expand ~ to home directory
63
+ */
64
+ function expandTilde(p: string): string {
65
+ if (p === '~' || p.startsWith('~/')) {
66
+ return join(HOME, p.slice(1))
67
+ }
68
+ return p
69
+ }
70
+
71
+ export function extractFilePath(input: Record<string, unknown>): string | undefined {
72
+ // Direct `path` field (replace/patch modes of edit, and write tool)
73
+ const directPath = input.path
74
+ if (typeof directPath === 'string' && directPath)
75
+ return directPath
76
+
77
+ // Hashline / apply-patch modes: `input` is a raw string containing the path
78
+ const rawInput = input.input
79
+ if (typeof rawInput !== 'string' || !rawInput)
80
+ return undefined
81
+
82
+ // Hashline: ¶path#hash or §path#hash or @path#hash
83
+ const hashlineMatch = /^[¶§@]([^\s#]+)/m.exec(rawInput)
84
+ if (hashlineMatch?.[1])
85
+ return hashlineMatch[1]
86
+
87
+ // Apply-patch: *** Add/Update/Delete File: path
88
+ const applyPatchMatch = /^\*\*\* (?:Add|Update|Delete) File:\s*(.+)/m.exec(rawInput)
89
+ if (applyPatchMatch?.[1])
90
+ return applyPatchMatch[1].trim()
91
+
92
+ return undefined
93
+ }
94
+
95
+ function isExistingFile(p: string): boolean {
96
+ try {
97
+ return statSync(p).isFile()
98
+ }
99
+ catch {
100
+ return false
101
+ }
102
+ }
103
+
104
+ function loadIgnorePatterns(cfgPath: string): string[] {
105
+ try {
106
+ const raw = readFileSync(cfgPath, 'utf8')
107
+ const cfg = JSON.parse(raw) as OxlintConfig
108
+ if (!Array.isArray(cfg.ignorePatterns))
109
+ return []
110
+ return cfg.ignorePatterns.filter((p): p is string => typeof p === 'string')
111
+ }
112
+ catch {
113
+ return []
114
+ }
115
+ }
116
+
117
+ function matchesIgnorePattern(filePath: string, patterns: string[]): boolean {
118
+ if (patterns.length === 0)
119
+ return false
120
+
121
+ const rel = relative(process.cwd(), filePath)
122
+ const candidates = [filePath, rel, `./${rel}`]
123
+
124
+ // Simple glob matching without Bun.Glob (for Node.js compatibility)
125
+ for (const pattern of patterns) {
126
+ const regex = globToRegex(pattern)
127
+ if (regex) {
128
+ for (const c of candidates) {
129
+ if (regex.test(c))
130
+ return true
131
+ }
132
+ }
133
+ }
134
+ return false
135
+ }
136
+
137
+ function globToRegex(glob: string): RegExp | null {
138
+ try {
139
+ // Convert glob to regex
140
+ const regexStr = glob
141
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&') // Escape special regex chars except * and ?
142
+ .replace(/\*\*/g, '{{DOUBLE_STAR}}') // Temporarily replace **
143
+ .replace(/\*/g, '[^/]*') // * matches anything except /
144
+ .replace(/\?/g, '[^/]') // ? matches single char except /
145
+ .replace(/\{\{DOUBLE_STAR\}\}/g, '.*') // ** matches everything
146
+
147
+ return new RegExp(`^${regexStr}$`)
148
+ }
149
+ catch {
150
+ return null
151
+ }
152
+ }
153
+
154
+ function runOxlint(filePath: string, cfgPath: string): { passed: boolean, output: string } {
155
+ const result = spawnSync('oxlint', ['-c', cfgPath, filePath], {
156
+ encoding: 'utf8',
157
+ stdio: ['ignore', 'pipe', 'pipe'],
158
+ timeout: 5000, // 5 second timeout
159
+ })
160
+
161
+ if (result.error) {
162
+ // oxlint not found or spawn error — treat as pass (fail-open)
163
+ return { passed: true, output: `oxlint error: ${result.error.message}` }
164
+ }
165
+
166
+ const exitCode = result.status ?? -1
167
+ const output = `${result.stdout ?? ''}${result.stderr ?? ''}`.trim()
168
+
169
+ // exit 0 = pass, exit 1 = violations found, other = tool error (fail-open)
170
+ return { passed: exitCode !== 1, output }
171
+ }
172
+
173
+ function runOxlintFix(filePath: string, cfgPath: string): { fixed: boolean, remaining: number, output: string } {
174
+ const result = spawnSync('oxlint', ['--fix', '-c', cfgPath, filePath], {
175
+ encoding: 'utf8',
176
+ stdio: ['ignore', 'pipe', 'pipe'],
177
+ timeout: 10000,
178
+ })
179
+
180
+ if (result.error) {
181
+ return { fixed: false, remaining: -1, output: `oxlint error: ${result.error.message}` }
182
+ }
183
+
184
+ const exitCode = result.status ?? -1
185
+ const output = `${result.stdout ?? ''}${result.stderr ?? ''}`.trim()
186
+
187
+ // exit 0 = all fixed, exit 1 = remaining violations
188
+ return { fixed: exitCode === 0, remaining: exitCode === 1 ? 1 : 0, output }
189
+ }
190
+
191
+ function truncateOutput(output: string, maxLines: number = MAX_OUTPUT_LINES): string {
192
+ const lines = output.split('\n')
193
+ if (lines.length <= maxLines)
194
+ return output
195
+ const head = lines.slice(0, 10).join('\n')
196
+ const summary = lines.slice(-5).join('\n')
197
+ return `${head}\n\n... (${lines.length - 15} lines truncated) ...\n\n${summary}`
198
+ }
199
+
200
+ const pendingPaths = new Map<string, { toolName: string, timestamp: number }>()
201
+ const fixCounters = new Map<string, number>()
202
+
203
+ const oxlintGate: ExtensionFactory = (pi: ExtensionAPI): void => {
204
+ const log = pi.logger
205
+
206
+ log.info('[oxlint-gate] extension loaded (auto-fix mode)')
207
+ writeLog('INFO', 'extension loaded (auto-fix mode)')
208
+
209
+ // ── tool_call: record file path, don't block ────────────────────────
210
+ pi.on('tool_call', async (event, ctx) => {
211
+ if (!WRITE_TOOLS.has(event.toolName))
212
+ return
213
+
214
+ const extractedPath = extractFilePath(event.input as Record<string, unknown>)
215
+ if (!extractedPath)
216
+ return
217
+
218
+ const expandedPath = expandTilde(extractedPath)
219
+ const filePath = isAbsolute(expandedPath) ? expandedPath : resolve(ctx.cwd, expandedPath)
220
+
221
+ if (!TS_EXTENSIONS.test(filePath))
222
+ return
223
+ if (!isExistingFile(filePath))
224
+ return
225
+
226
+ pendingPaths.set(filePath, { toolName: event.toolName, timestamp: Date.now() })
227
+ return undefined
228
+ })
229
+
230
+ // ── tool_result: check & auto-fix ───────────────────────────────────
231
+ pi.on('tool_result', async (event, ctx) => {
232
+ if (!WRITE_TOOLS.has(event.toolName))
233
+ return
234
+
235
+ const extractedPath = extractFilePath(event.input as Record<string, unknown>)
236
+ if (!extractedPath)
237
+ return
238
+
239
+ const expandedPath = expandTilde(extractedPath)
240
+ const filePath = isAbsolute(expandedPath) ? expandedPath : resolve(ctx.cwd, expandedPath)
241
+
242
+ const pending = pendingPaths.get(filePath)
243
+ pendingPaths.delete(filePath)
244
+ if (!pending)
245
+ return
246
+
247
+ if (!TS_EXTENSIONS.test(filePath))
248
+ return
249
+ if (!isExistingFile(filePath))
250
+ return
251
+ if (!existsSync(OXLINT_CFG))
252
+ return
253
+
254
+ const ignorePatterns = loadIgnorePatterns(OXLINT_CFG)
255
+ if (matchesIgnorePattern(filePath, ignorePatterns))
256
+ return
257
+
258
+ const fixCount = fixCounters.get(filePath) ?? 0
259
+ if (fixCount >= MAX_FIX_ATTEMPTS) {
260
+ log.debug(`[oxlint-gate] max fix attempts (${MAX_FIX_ATTEMPTS}) reached for ${filePath}`)
261
+ return
262
+ }
263
+
264
+ // 1. Check for violations
265
+ const { passed } = runOxlint(filePath, OXLINT_CFG)
266
+ if (passed) {
267
+ log.info(`[oxlint-gate] passed: ${filePath}`)
268
+ writeLog('INFO', `passed: ${filePath}`)
269
+ fixCounters.delete(filePath) // reset counter on clean pass
270
+ return
271
+ }
272
+
273
+ log.warn(`[oxlint-gate] violations in ${filePath}, attempting auto-fix`)
274
+ writeLog('WARN', `violations in ${filePath}, attempting auto-fix`)
275
+
276
+ // 2. Try auto-fix
277
+ const fixResult = runOxlintFix(filePath, OXLINT_CFG)
278
+ fixCounters.set(filePath, fixCount + 1)
279
+
280
+ if (fixResult.fixed) {
281
+ log.info(`[oxlint-gate] auto-fixed: ${filePath}`)
282
+ writeLog('INFO', `auto-fixed: ${filePath}`)
283
+ return {
284
+ content: [{ type: 'text', text: `✅ [oxlint-gate] auto-fixed lint issues in ${filePath}` }],
285
+ }
286
+ }
287
+
288
+ // 3. Some violations remain — report to LLM
289
+ const remaining = truncateOutput(fixResult.output)
290
+ log.warn(`[oxlint-gate] partial fix in ${filePath}, remaining issues`)
291
+ writeLog('WARN', `partial fix in ${filePath}`)
292
+
293
+ pi.sendMessage(
294
+ {
295
+ customType: 'oxlint-gate',
296
+ content: `⚠️ [oxlint-gate] ${filePath} has remaining lint issues after auto-fix:\n\n${remaining}`,
297
+ display: true,
298
+ attribution: 'agent',
299
+ },
300
+ { triggerTurn: false },
301
+ )
302
+ return undefined
303
+ })
304
+
305
+ // ── turn_end: clear pending paths only (keep fixCounters to prevent loops) ──
306
+ pi.on('turn_end', async () => {
307
+ pendingPaths.clear()
308
+ })
309
+ }
310
+
311
+ export default oxlintGate
@@ -0,0 +1,129 @@
1
+ /**
2
+ * OMP Extension type bridge.
3
+ *
4
+ * Re-exports the subset of extension types needed by this plugin.
5
+ * At runtime these resolve via OMP's package exports.
6
+ * For type-checking outside OMP, types are inlined below as a fallback.
7
+ */
8
+
9
+ // ── OMP runtime types (resolved when loaded inside OMP) ────────────────
10
+ // The extension factory receives ExtensionAPI; we only need the event shapes
11
+ // and the handler signature for type-safe `pi.on()` calls.
12
+
13
+ /** Fired on initial session load */
14
+ export interface SessionStartEvent {
15
+ type: 'session_start'
16
+ }
17
+
18
+ /**
19
+ * Fired before a tool executes. Discriminated union by toolName.
20
+ * For the `edit` tool, input is Record<string, unknown> because
21
+ * the edit tool accepts 4 different schema modes (replace, patch,
22
+ * hashline, apply-patch).
23
+ */
24
+ export interface EditToolCallEvent {
25
+ type: 'tool_call'
26
+ toolName: 'edit'
27
+ toolCallId: string
28
+ input: Record<string, unknown>
29
+ }
30
+
31
+ export interface WriteToolCallEvent {
32
+ type: 'tool_call'
33
+ toolName: 'write'
34
+ toolCallId: string
35
+ input: { path: string, content: string }
36
+ }
37
+
38
+ export interface ReadToolCallEvent {
39
+ type: 'tool_call'
40
+ toolName: 'read'
41
+ toolCallId: string
42
+ input: { path: string }
43
+ }
44
+
45
+ export interface BashToolCallEvent {
46
+ type: 'tool_call'
47
+ toolName: 'bash'
48
+ toolCallId: string
49
+ input: { command: string, env?: Record<string, string>, timeout?: number, cwd?: string }
50
+ }
51
+
52
+ export interface SearchToolCallEvent {
53
+ type: 'tool_call'
54
+ toolName: 'search'
55
+ toolCallId: string
56
+ input: { pattern: string, paths: string | string[], i?: boolean, gitignore?: boolean }
57
+ }
58
+
59
+ export interface FindToolCallEvent {
60
+ type: 'tool_call'
61
+ toolName: 'find'
62
+ toolCallId: string
63
+ input: { paths: string[], hidden?: boolean, gitignore?: boolean }
64
+ }
65
+
66
+ export interface CustomToolCallEvent {
67
+ type: 'tool_call'
68
+ toolName: string
69
+ toolCallId: string
70
+ input: Record<string, unknown>
71
+ }
72
+
73
+ export type ToolCallEvent
74
+ = | BashToolCallEvent
75
+ | ReadToolCallEvent
76
+ | EditToolCallEvent
77
+ | WriteToolCallEvent
78
+ | SearchToolCallEvent
79
+ | FindToolCallEvent
80
+ | CustomToolCallEvent
81
+
82
+ /** Fired after a tool executes. */
83
+ export interface ToolResultEvent {
84
+ type: 'tool_result'
85
+ toolName: string
86
+ toolCallId: string
87
+ input: Record<string, unknown>
88
+ content: ({ type: 'text', text: string } | { type: 'image', data: string, mimeType: string })[]
89
+ isError: boolean
90
+ }
91
+
92
+ /**
93
+ * Context passed to extension event handlers.
94
+ * We only need `cwd` from it.
95
+ */
96
+ export interface ExtensionContext {
97
+ cwd: string
98
+ }
99
+
100
+ /** Handler function type */
101
+ export type ExtensionHandler<E, R = undefined> = (
102
+ event: E,
103
+ ctx: ExtensionContext,
104
+ ) => Promise<R | void> | R | void
105
+
106
+ /** Extension factory function type */
107
+ export type ExtensionFactory = (pi: ExtensionAPI) => void | Promise<void>
108
+
109
+ /**
110
+ * ExtensionAPI — the `pi` object passed to the factory.
111
+ * We only declare the subset we use (on, logger).
112
+ */
113
+ export interface ExtensionAPI {
114
+ on: ((event: 'session_start', handler: ExtensionHandler<SessionStartEvent>) => void) & ((event: 'tool_call', handler: ExtensionHandler<ToolCallEvent, ToolCallEventResult | undefined>) => void) & ((event: 'tool_result', handler: ExtensionHandler<ToolResultEvent, ToolResultEventResult | undefined>) => void) & ((event: 'turn_end', handler: ExtensionHandler<{ type: 'turn_end' }>) => void)
115
+ logger: { debug: (msg: string) => void, info: (msg: string) => void, warn: (msg: string) => void, error: (msg: string) => void }
116
+ sendMessage: (message: { customType: string, content: string, display: boolean, attribution: string }, options?: { triggerTurn?: boolean }) => void
117
+ }
118
+
119
+ /** Return type for tool_call handlers (can block) */
120
+ export interface ToolCallEventResult {
121
+ block?: boolean
122
+ reason?: string
123
+ }
124
+
125
+ /** Return type for tool_result handlers (can modify result) */
126
+ export interface ToolResultEventResult {
127
+ content?: ({ type: 'text', text: string } | { type: 'image', data: string, mimeType: string })[]
128
+ isError?: boolean
129
+ }