oc-tweaks 0.1.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 +333 -0
- package/package.json +27 -0
- package/src/__tests__/.gitkeep +0 -0
- package/src/__tests__/background-subagent.test.ts +106 -0
- package/src/__tests__/cli-init.test.ts +70 -0
- package/src/__tests__/compaction.test.ts +113 -0
- package/src/__tests__/index.test.ts +180 -0
- package/src/__tests__/leaderboard.test.ts +244 -0
- package/src/__tests__/logger.test.ts +84 -0
- package/src/__tests__/notify.test.ts +318 -0
- package/src/__tests__/utils.test.ts +164 -0
- package/src/bun-test.d.ts +12 -0
- package/src/cli/init.ts +44 -0
- package/src/index.ts +4 -0
- package/src/plugins/.gitkeep +0 -0
- package/src/plugins/background-subagent.ts +59 -0
- package/src/plugins/compaction.ts +28 -0
- package/src/plugins/leaderboard.ts +184 -0
- package/src/plugins/notify.ts +383 -0
- package/src/types.ts +2 -0
- package/src/utils/.gitkeep +0 -0
- package/src/utils/config.ts +71 -0
- package/src/utils/index.ts +5 -0
- package/src/utils/logger.ts +52 -0
- package/src/utils/safe-hook.ts +16 -0
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
|
|
3
|
+
import { afterEach, describe, expect, test } from "bun:test"
|
|
4
|
+
|
|
5
|
+
import { notifyPlugin } from "../plugins/notify"
|
|
6
|
+
|
|
7
|
+
const originalBunFile = Bun.file
|
|
8
|
+
const originalHome = Bun.env?.HOME
|
|
9
|
+
|
|
10
|
+
function mockBunFile(mockData: Record<string, any>) {
|
|
11
|
+
;(globalThis as any).Bun.file = (path: string) => ({
|
|
12
|
+
exists: async () => path in mockData,
|
|
13
|
+
json: async () => {
|
|
14
|
+
if (!(path in mockData)) throw new Error("ENOENT")
|
|
15
|
+
const data = mockData[path]
|
|
16
|
+
if (data instanceof Error) throw data
|
|
17
|
+
return data
|
|
18
|
+
},
|
|
19
|
+
text: async () => JSON.stringify(mockData[path] ?? ""),
|
|
20
|
+
})
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function createShellMock(options?: {
|
|
24
|
+
availableCommands?: string[]
|
|
25
|
+
throwOnExecution?: boolean
|
|
26
|
+
}) {
|
|
27
|
+
const available = new Set(options?.availableCommands ?? [])
|
|
28
|
+
const calls: Array<{ command: string; values: any[] }> = []
|
|
29
|
+
|
|
30
|
+
const $ = async (strings: TemplateStringsArray, ...values: any[]) => {
|
|
31
|
+
const segments = Array.from(strings)
|
|
32
|
+
const command = segments.reduce(
|
|
33
|
+
(acc, segment, index) =>
|
|
34
|
+
acc + segment + (index < values.length ? String(values[index]) : ""),
|
|
35
|
+
"",
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
calls.push({ command, values })
|
|
39
|
+
|
|
40
|
+
if (command.startsWith("which ")) {
|
|
41
|
+
const bin = String(values[0] ?? command.slice("which ".length).trim())
|
|
42
|
+
if (available.has(bin)) return { stdout: `${bin}\n` }
|
|
43
|
+
throw new Error(`missing ${bin}`)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (options?.throwOnExecution) {
|
|
47
|
+
throw new Error("execution failed")
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return { stdout: "" }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return { $, calls }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
afterEach(() => {
|
|
58
|
+
;(globalThis as any).Bun.file = originalBunFile
|
|
59
|
+
if (originalHome === undefined) {
|
|
60
|
+
delete (Bun.env as any).HOME
|
|
61
|
+
} else {
|
|
62
|
+
;(Bun.env as any).HOME = originalHome
|
|
63
|
+
}
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
describe("notifyPlugin", () => {
|
|
67
|
+
test("returns empty hooks when notify.enabled is false", async () => {
|
|
68
|
+
const home = "/tmp/oc-notify-disabled"
|
|
69
|
+
;(Bun.env as any).HOME = home
|
|
70
|
+
const path = `${home}/.config/opencode/oc-tweaks.json`
|
|
71
|
+
mockBunFile({
|
|
72
|
+
[path]: {
|
|
73
|
+
notify: { enabled: false },
|
|
74
|
+
},
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
const { $ } = createShellMock({ availableCommands: ["notify-send"] })
|
|
78
|
+
const hooks = await notifyPlugin({ $, directory: "/tmp/demo", client: {} })
|
|
79
|
+
|
|
80
|
+
expect(hooks).toEqual({})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test("auto-detects notifier once and caches detection result", async () => {
|
|
84
|
+
const home = "/tmp/oc-notify-detect"
|
|
85
|
+
;(Bun.env as any).HOME = home
|
|
86
|
+
const path = `${home}/.config/opencode/oc-tweaks.json`
|
|
87
|
+
mockBunFile({ [path]: { notify: { enabled: true } } })
|
|
88
|
+
|
|
89
|
+
const { $, calls } = createShellMock({ availableCommands: ["notify-send"] })
|
|
90
|
+
const client = {
|
|
91
|
+
session: {
|
|
92
|
+
messages: async () => ({
|
|
93
|
+
data: [
|
|
94
|
+
{ info: { role: "user" }, parts: [{ type: "text", text: "irrelevant" }] },
|
|
95
|
+
{ info: { role: "assistant" }, parts: [{ type: "text", text: "**Done** now" }] },
|
|
96
|
+
],
|
|
97
|
+
}),
|
|
98
|
+
},
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const hooks = await notifyPlugin({ $, directory: "/tmp/demo", client })
|
|
102
|
+
await hooks.event({ event: { type: "session.idle", properties: { sessionID: "s1" } } })
|
|
103
|
+
await hooks.event({ event: { type: "session.error", properties: {} } })
|
|
104
|
+
|
|
105
|
+
const whichCalls = calls.filter((entry) => entry.command.startsWith("which "))
|
|
106
|
+
const notifyCalls = calls.filter((entry) => entry.command.startsWith("notify-send "))
|
|
107
|
+
|
|
108
|
+
expect(whichCalls.length).toBe(4)
|
|
109
|
+
expect(notifyCalls.length).toBe(2)
|
|
110
|
+
expect(notifyCalls[0].command).toContain("notify-send oc: demo")
|
|
111
|
+
expect(notifyCalls[0].command).toContain("✓ Done now")
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
test("uses custom command and replaces $TITLE/$MESSAGE placeholders", async () => {
|
|
115
|
+
const home = "/tmp/oc-notify-custom"
|
|
116
|
+
;(Bun.env as any).HOME = home
|
|
117
|
+
const path = `${home}/.config/opencode/oc-tweaks.json`
|
|
118
|
+
mockBunFile({
|
|
119
|
+
[path]: {
|
|
120
|
+
notify: {
|
|
121
|
+
enabled: true,
|
|
122
|
+
command: "custom-bin --title $TITLE --message $MESSAGE",
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
const { $, calls } = createShellMock()
|
|
128
|
+
const client = {
|
|
129
|
+
session: {
|
|
130
|
+
messages: async () => ({
|
|
131
|
+
data: [{ info: { role: "assistant" }, parts: [{ type: "text", text: "Hello *world*" }] }],
|
|
132
|
+
}),
|
|
133
|
+
},
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const hooks = await notifyPlugin({ $, directory: "/tmp/demo", client })
|
|
137
|
+
await hooks.event({ event: { type: "session.idle", properties: { sessionID: "s2" } } })
|
|
138
|
+
|
|
139
|
+
const whichCalls = calls.filter((entry) => entry.command.startsWith("which "))
|
|
140
|
+
const executeCalls = calls.filter((entry) => !entry.command.startsWith("which "))
|
|
141
|
+
|
|
142
|
+
expect(whichCalls.length).toBe(0)
|
|
143
|
+
expect(executeCalls.length).toBe(1)
|
|
144
|
+
expect(executeCalls[0].command).toContain("custom-bin --title oc: demo --message ✓ Hello world")
|
|
145
|
+
expect(executeCalls[0].command).not.toContain("$TITLE")
|
|
146
|
+
expect(executeCalls[0].command).not.toContain("$MESSAGE")
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
test("does not notify when notifyOnIdle/notifyOnError are false", async () => {
|
|
150
|
+
const home = "/tmp/oc-notify-switches"
|
|
151
|
+
;(Bun.env as any).HOME = home
|
|
152
|
+
const path = `${home}/.config/opencode/oc-tweaks.json`
|
|
153
|
+
mockBunFile({
|
|
154
|
+
[path]: {
|
|
155
|
+
notify: {
|
|
156
|
+
enabled: true,
|
|
157
|
+
notifyOnIdle: false,
|
|
158
|
+
notifyOnError: false,
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
const { $, calls } = createShellMock({ availableCommands: ["notify-send"] })
|
|
164
|
+
const hooks = await notifyPlugin({ $, directory: "/tmp/demo", client: {} })
|
|
165
|
+
|
|
166
|
+
await hooks.event({ event: { type: "session.idle", properties: { sessionID: "s3" } } })
|
|
167
|
+
await hooks.event({ event: { type: "session.error", properties: {} } })
|
|
168
|
+
|
|
169
|
+
const notifyCalls = calls.filter((entry) => !entry.command.startsWith("which "))
|
|
170
|
+
expect(notifyCalls.length).toBe(0)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
test("falls back to client.tui.showToast when no shell notifier exists", async () => {
|
|
174
|
+
const home = "/tmp/oc-notify-tui"
|
|
175
|
+
;(Bun.env as any).HOME = home
|
|
176
|
+
const path = `${home}/.config/opencode/oc-tweaks.json`
|
|
177
|
+
mockBunFile({ [path]: { notify: { enabled: true } } })
|
|
178
|
+
|
|
179
|
+
const { $, calls } = createShellMock()
|
|
180
|
+
const toastCalls: any[] = []
|
|
181
|
+
const client = {
|
|
182
|
+
tui: {
|
|
183
|
+
showToast: (...args: any[]) => {
|
|
184
|
+
toastCalls.push(args)
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const hooks = await notifyPlugin({ $, directory: "/tmp/demo", client })
|
|
190
|
+
await hooks.event({ event: { type: "session.error", properties: {} } })
|
|
191
|
+
|
|
192
|
+
const whichCalls = calls.filter((entry) => entry.command.startsWith("which "))
|
|
193
|
+
expect(whichCalls.length).toBe(4)
|
|
194
|
+
expect(toastCalls.length).toBe(1)
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
test("degrades silently when execution fails and does not throw", async () => {
|
|
198
|
+
const home = "/tmp/oc-notify-non-blocking"
|
|
199
|
+
;(Bun.env as any).HOME = home
|
|
200
|
+
const path = `${home}/.config/opencode/oc-tweaks.json`
|
|
201
|
+
mockBunFile({ [path]: { notify: { enabled: true } } })
|
|
202
|
+
|
|
203
|
+
const { $ } = createShellMock({
|
|
204
|
+
availableCommands: ["notify-send"],
|
|
205
|
+
throwOnExecution: true,
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
const hooks = await notifyPlugin({ $, directory: "/tmp/demo", client: {} })
|
|
209
|
+
|
|
210
|
+
await expect(
|
|
211
|
+
hooks.event({ event: { type: "session.error", properties: {} } }),
|
|
212
|
+
).resolves.toBeUndefined()
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
test("keeps silent when no notifier is available", async () => {
|
|
216
|
+
const home = "/tmp/oc-notify-none"
|
|
217
|
+
;(Bun.env as any).HOME = home
|
|
218
|
+
const path = `${home}/.config/opencode/oc-tweaks.json`
|
|
219
|
+
mockBunFile({ [path]: { notify: { enabled: true } } })
|
|
220
|
+
|
|
221
|
+
const { $ } = createShellMock()
|
|
222
|
+
const hooks = await notifyPlugin({ $, directory: "/tmp/demo", client: {} })
|
|
223
|
+
|
|
224
|
+
await expect(
|
|
225
|
+
hooks.event({ event: { type: "session.error", properties: {} } }),
|
|
226
|
+
).resolves.toBeUndefined()
|
|
227
|
+
})
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
test("detects pwsh and uses wpf sender", async () => {
|
|
231
|
+
const home = "/tmp/oc-notify-wpf-detect"
|
|
232
|
+
;(Bun.env as any).HOME = home
|
|
233
|
+
const path = `${home}/.config/opencode/oc-tweaks.json`
|
|
234
|
+
mockBunFile({ [path]: { notify: { enabled: true } } })
|
|
235
|
+
|
|
236
|
+
const { $, calls } = createShellMock({ availableCommands: ["pwsh"] })
|
|
237
|
+
const client = {
|
|
238
|
+
session: {
|
|
239
|
+
messages: async () => ({
|
|
240
|
+
data: [{ info: { role: "assistant" }, parts: [{ type: "text", text: "Task done" }] }],
|
|
241
|
+
}),
|
|
242
|
+
},
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const hooks = await notifyPlugin({ $, directory: "/tmp/demo", client })
|
|
246
|
+
await hooks.event({ event: { type: "session.idle", properties: { sessionID: "s4" } } })
|
|
247
|
+
|
|
248
|
+
const pwshCalls = calls.filter((entry) => entry.command.includes("pwsh"))
|
|
249
|
+
expect(pwshCalls.length).toBeGreaterThan(0)
|
|
250
|
+
|
|
251
|
+
const bun_e_calls = calls.filter((entry) => entry.command.includes("bun -e"))
|
|
252
|
+
expect(bun_e_calls.length).toBeGreaterThan(0)
|
|
253
|
+
|
|
254
|
+
const jsCode = String(bun_e_calls[0].values[0] ?? "")
|
|
255
|
+
expect(jsCode).toContain("PresentationFramework")
|
|
256
|
+
expect(jsCode).toContain("ShowActivated")
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
test("wpf notification script contains WS_EX_NOACTIVATE for non-focus behavior", async () => {
|
|
260
|
+
const home = "/tmp/oc-notify-wpf-noactivate"
|
|
261
|
+
;(Bun.env as any).HOME = home
|
|
262
|
+
const path = `${home}/.config/opencode/oc-tweaks.json`
|
|
263
|
+
mockBunFile({ [path]: { notify: { enabled: true } } })
|
|
264
|
+
|
|
265
|
+
const { $, calls } = createShellMock({ availableCommands: ["pwsh"] })
|
|
266
|
+
const client = {
|
|
267
|
+
session: {
|
|
268
|
+
messages: async () => ({
|
|
269
|
+
data: [{ info: { role: "assistant" }, parts: [{ type: "text", text: "Finished" }] }],
|
|
270
|
+
}),
|
|
271
|
+
},
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const hooks = await notifyPlugin({ $, directory: "/tmp/demo", client })
|
|
275
|
+
await hooks.event({ event: { type: "session.idle", properties: { sessionID: "s5" } } })
|
|
276
|
+
|
|
277
|
+
const bun_e_calls = calls.filter((entry) => entry.command.includes("bun -e"))
|
|
278
|
+
expect(bun_e_calls.length).toBeGreaterThan(0)
|
|
279
|
+
|
|
280
|
+
const jsCode = String(bun_e_calls[0].values[0] ?? "")
|
|
281
|
+
expect(jsCode).toMatch(/WS_EX_NOACTIVATE|0x08000000|MakeGlobalWindow/)
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
test("wpf notification uses custom style from config", async () => {
|
|
285
|
+
const home = "/tmp/oc-notify-wpf-style"
|
|
286
|
+
;(Bun.env as any).HOME = home
|
|
287
|
+
const path = `${home}/.config/opencode/oc-tweaks.json`
|
|
288
|
+
mockBunFile({
|
|
289
|
+
[path]: {
|
|
290
|
+
notify: {
|
|
291
|
+
enabled: true,
|
|
292
|
+
style: {
|
|
293
|
+
backgroundColor: "#FF0000",
|
|
294
|
+
duration: 3000,
|
|
295
|
+
},
|
|
296
|
+
},
|
|
297
|
+
},
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
const { $, calls } = createShellMock({ availableCommands: ["pwsh"] })
|
|
301
|
+
const client = {
|
|
302
|
+
session: {
|
|
303
|
+
messages: async () => ({
|
|
304
|
+
data: [{ info: { role: "assistant" }, parts: [{ type: "text", text: "Complete" }] }],
|
|
305
|
+
}),
|
|
306
|
+
},
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const hooks = await notifyPlugin({ $, directory: "/tmp/demo", client })
|
|
310
|
+
await hooks.event({ event: { type: "session.idle", properties: { sessionID: "s6" } } })
|
|
311
|
+
|
|
312
|
+
const bun_e_calls = calls.filter((entry) => entry.command.includes("bun -e"))
|
|
313
|
+
expect(bun_e_calls.length).toBeGreaterThan(0)
|
|
314
|
+
|
|
315
|
+
const jsCode = String(bun_e_calls[0].values[0] ?? "")
|
|
316
|
+
expect(jsCode).toContain("#FF0000")
|
|
317
|
+
expect(jsCode).toContain("3000")
|
|
318
|
+
})
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
|
|
3
|
+
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
|
|
4
|
+
|
|
5
|
+
import { safeHook } from "../utils/safe-hook"
|
|
6
|
+
import { loadJsonConfig, loadOcTweaksConfig } from "../utils/config"
|
|
7
|
+
|
|
8
|
+
const originalBunFile = Bun.file
|
|
9
|
+
const originalHome = Bun.env?.HOME
|
|
10
|
+
|
|
11
|
+
function mockBunFile(mockData: Record<string, any>) {
|
|
12
|
+
;(globalThis as any).Bun.file = (path: string) => ({
|
|
13
|
+
exists: async () => path in mockData,
|
|
14
|
+
json: async () => {
|
|
15
|
+
if (!(path in mockData)) throw new Error("ENOENT")
|
|
16
|
+
const data = mockData[path]
|
|
17
|
+
if (data instanceof Error) throw data
|
|
18
|
+
return data
|
|
19
|
+
},
|
|
20
|
+
text: async () => JSON.stringify(mockData[path] ?? ""),
|
|
21
|
+
})
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
;(globalThis as any).Bun.file = originalBunFile
|
|
26
|
+
if (originalHome === undefined) {
|
|
27
|
+
delete (Bun.env as any).HOME
|
|
28
|
+
} else {
|
|
29
|
+
;(Bun.env as any).HOME = originalHome
|
|
30
|
+
}
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
describe("safeHook", () => {
|
|
34
|
+
function createWarnSpy() {
|
|
35
|
+
const calls: any[] = []
|
|
36
|
+
const originalWarn = console.warn
|
|
37
|
+
console.warn = (...args: any[]) => {
|
|
38
|
+
calls.push(args)
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
calls,
|
|
42
|
+
restore: () => {
|
|
43
|
+
console.warn = originalWarn
|
|
44
|
+
},
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let warnSpy: ReturnType<typeof createWarnSpy>
|
|
49
|
+
|
|
50
|
+
beforeEach(() => {
|
|
51
|
+
warnSpy = createWarnSpy()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
afterEach(() => {
|
|
55
|
+
warnSpy.restore()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test("returns original function result", async () => {
|
|
59
|
+
const wrapped = safeHook("add", (value: number) => value + 1)
|
|
60
|
+
const result = await Promise.resolve(wrapped(1))
|
|
61
|
+
|
|
62
|
+
expect(result).toBe(2)
|
|
63
|
+
expect(warnSpy.calls.length).toBe(0)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test("swallows errors silently (now uses logger, not console.warn)", async () => {
|
|
67
|
+
const error = new Error("boom")
|
|
68
|
+
const wrapped = safeHook("fail", () => {
|
|
69
|
+
throw error
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
// Should not throw, and should not call console.warn (logger is used instead)
|
|
73
|
+
await expect(Promise.resolve(wrapped())).resolves.toBeUndefined()
|
|
74
|
+
expect(warnSpy.calls.length).toBe(0)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test("works with async functions", async () => {
|
|
78
|
+
const wrapped = safeHook("async", async (value: number) => value * 2)
|
|
79
|
+
const result = await wrapped(3)
|
|
80
|
+
|
|
81
|
+
expect(result).toBe(6)
|
|
82
|
+
expect(warnSpy.calls.length).toBe(0)
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
describe("loadJsonConfig", () => {
|
|
87
|
+
test("returns defaults when file is missing", async () => {
|
|
88
|
+
mockBunFile({})
|
|
89
|
+
const defaults = { a: 1, b: true }
|
|
90
|
+
|
|
91
|
+
const result = await loadJsonConfig("/tmp/missing.json", defaults)
|
|
92
|
+
|
|
93
|
+
expect(result).toEqual(defaults)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test("merges defaults with parsed config", async () => {
|
|
97
|
+
const path = "/tmp/config.json"
|
|
98
|
+
mockBunFile({
|
|
99
|
+
[path]: {
|
|
100
|
+
b: false,
|
|
101
|
+
c: "extra",
|
|
102
|
+
},
|
|
103
|
+
})
|
|
104
|
+
const defaults = { a: 1, b: true }
|
|
105
|
+
|
|
106
|
+
const result = await loadJsonConfig(path, defaults)
|
|
107
|
+
|
|
108
|
+
expect(result).toEqual({ a: 1, b: false, c: "extra" })
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
test("returns defaults when json parsing fails", async () => {
|
|
112
|
+
const path = "/tmp/bad.json"
|
|
113
|
+
mockBunFile({
|
|
114
|
+
[path]: new Error("invalid json"),
|
|
115
|
+
})
|
|
116
|
+
const defaults = { enabled: true }
|
|
117
|
+
|
|
118
|
+
const result = await loadJsonConfig(path, defaults)
|
|
119
|
+
|
|
120
|
+
expect(result).toEqual(defaults)
|
|
121
|
+
})
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
describe("loadOcTweaksConfig", () => {
|
|
125
|
+
test("loads config from default path", async () => {
|
|
126
|
+
const home = "/tmp/oc-home"
|
|
127
|
+
;(Bun.env as any).HOME = home
|
|
128
|
+
const path = `${home}/.config/opencode/oc-tweaks.json`
|
|
129
|
+
|
|
130
|
+
mockBunFile({
|
|
131
|
+
[path]: {
|
|
132
|
+
notify: {
|
|
133
|
+
enabled: false,
|
|
134
|
+
notifyOnIdle: true,
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
const result = await loadOcTweaksConfig()
|
|
140
|
+
|
|
141
|
+
expect(result.notify.enabled).toBe(false)
|
|
142
|
+
expect(result.notify.notifyOnIdle).toBe(true)
|
|
143
|
+
expect(result.compaction).toEqual({})
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
test("string \"false\" is not treated as disabled", async () => {
|
|
147
|
+
const home = "/tmp/oc-home-2"
|
|
148
|
+
;(Bun.env as any).HOME = home
|
|
149
|
+
const path = `${home}/.config/opencode/oc-tweaks.json`
|
|
150
|
+
|
|
151
|
+
mockBunFile({
|
|
152
|
+
[path]: {
|
|
153
|
+
notify: {
|
|
154
|
+
enabled: "false",
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
const result = await loadOcTweaksConfig()
|
|
160
|
+
|
|
161
|
+
expect(result.notify.enabled).toBe("false")
|
|
162
|
+
expect(result.notify.enabled === false).toBe(false)
|
|
163
|
+
})
|
|
164
|
+
})
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/// <reference types="bun-types" />
|
|
2
|
+
|
|
3
|
+
declare const Bun: any
|
|
4
|
+
|
|
5
|
+
declare module "bun:test" {
|
|
6
|
+
export const describe: (...args: any[]) => any
|
|
7
|
+
export const test: (...args: any[]) => any
|
|
8
|
+
export const expect: any
|
|
9
|
+
export const mock: any
|
|
10
|
+
export const beforeEach: any
|
|
11
|
+
export const afterEach: any
|
|
12
|
+
}
|
package/src/cli/init.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import { mkdir } from "node:fs/promises"
|
|
4
|
+
import { dirname } from "node:path"
|
|
5
|
+
declare const Bun: any
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_CONFIG = {
|
|
8
|
+
notify: { enabled: true },
|
|
9
|
+
compaction: { enabled: true },
|
|
10
|
+
backgroundSubagent: { enabled: true },
|
|
11
|
+
leaderboard: { enabled: false },
|
|
12
|
+
logging: { enabled: false, maxLines: 200 }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function initConfig(): Promise<{ created: boolean; path: string }> {
|
|
16
|
+
const home = Bun.env?.HOME ?? ((globalThis as any)?.process?.env?.HOME ?? "") ?? ""
|
|
17
|
+
const configPath = `${home}/.config/opencode/oc-tweaks.json`
|
|
18
|
+
|
|
19
|
+
const file = Bun.file(configPath)
|
|
20
|
+
if (await file.exists()) {
|
|
21
|
+
return { created: false, path: configPath }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const json = JSON.stringify(DEFAULT_CONFIG, null, 2)
|
|
25
|
+
await mkdir(dirname(configPath), { recursive: true })
|
|
26
|
+
await Bun.write(configPath, json + "\n")
|
|
27
|
+
|
|
28
|
+
return { created: true, path: configPath }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Only run when executed directly
|
|
32
|
+
const isMain = typeof Bun !== "undefined" &&
|
|
33
|
+
Bun.main === import.meta.path
|
|
34
|
+
|
|
35
|
+
if (isMain) {
|
|
36
|
+
const result = await initConfig()
|
|
37
|
+
if (result.created) {
|
|
38
|
+
console.log(`Created: ${result.path}`)
|
|
39
|
+
console.log("All plugins configured. Edit the file to customize.")
|
|
40
|
+
} else {
|
|
41
|
+
console.log(`Config already exists: ${result.path}`)
|
|
42
|
+
console.log("Nothing changed. Edit the file manually to update your configuration.")
|
|
43
|
+
}
|
|
44
|
+
}
|
package/src/index.ts
ADDED
|
File without changes
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { Plugin } from "@opencode-ai/plugin"
|
|
2
|
+
|
|
3
|
+
import { loadOcTweaksConfig, safeHook } from "../utils"
|
|
4
|
+
|
|
5
|
+
const SUB_AGENT_DISPATCH_PROMPT = `
|
|
6
|
+
## Sub-Agent Dispatch Policy
|
|
7
|
+
|
|
8
|
+
When calling \`task()\` to dispatch sub-agents, you should default to \`run_in_background=true\`.
|
|
9
|
+
This keeps the main conversation responsive while sub-agents work in the background.
|
|
10
|
+
|
|
11
|
+
Only use \`run_in_background=false\` when ALL of these conditions are met:
|
|
12
|
+
1. The next step cannot easily proceed without the sub-agent's result
|
|
13
|
+
2. There is NO other useful work to do while waiting
|
|
14
|
+
3. The user is explicitly waiting for that specific result
|
|
15
|
+
|
|
16
|
+
When in doubt → background. Use \`background_output()\` to collect results later.
|
|
17
|
+
`
|
|
18
|
+
|
|
19
|
+
const VIOLATION_WARNING = `💡 [Reminder] Consider using background mode for better responsiveness.
|
|
20
|
+
You used foreground mode (run_in_background=false). Check the three conditions in the system prompt.
|
|
21
|
+
If not all three are met, consider run_in_background=true + background_output() for next time.`
|
|
22
|
+
|
|
23
|
+
export const backgroundSubagentPlugin: Plugin = async () => {
|
|
24
|
+
const config = await loadOcTweaksConfig()
|
|
25
|
+
if (!config || config.backgroundSubagent?.enabled !== true) return {}
|
|
26
|
+
|
|
27
|
+
const foregroundCalls = new Set<string>()
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
"experimental.chat.system.transform": safeHook(
|
|
31
|
+
"background-subagent:system.transform",
|
|
32
|
+
async (_input: unknown, output: { system: string[] }) => {
|
|
33
|
+
output.system.push(SUB_AGENT_DISPATCH_PROMPT)
|
|
34
|
+
},
|
|
35
|
+
),
|
|
36
|
+
|
|
37
|
+
"tool.execute.before": safeHook(
|
|
38
|
+
"background-subagent:tool.execute.before",
|
|
39
|
+
async (
|
|
40
|
+
input: { tool: string; callID: string },
|
|
41
|
+
output: { args?: { run_in_background?: boolean } },
|
|
42
|
+
) => {
|
|
43
|
+
if (input.tool !== "task") return
|
|
44
|
+
if (!output.args?.run_in_background) {
|
|
45
|
+
foregroundCalls.add(input.callID)
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
),
|
|
49
|
+
|
|
50
|
+
"tool.execute.after": safeHook(
|
|
51
|
+
"background-subagent:tool.execute.after",
|
|
52
|
+
async (input: { callID: string }, output: { output: string }) => {
|
|
53
|
+
if (!foregroundCalls.has(input.callID)) return
|
|
54
|
+
foregroundCalls.delete(input.callID)
|
|
55
|
+
output.output += `\n\n${VIOLATION_WARNING}`
|
|
56
|
+
},
|
|
57
|
+
),
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Plugin } from "@opencode-ai/plugin";
|
|
2
|
+
|
|
3
|
+
import { loadOcTweaksConfig, safeHook } from "../utils";
|
|
4
|
+
|
|
5
|
+
const LANGUAGE_PREFERENCE_PROMPT = `
|
|
6
|
+
## Language Preference
|
|
7
|
+
|
|
8
|
+
Important: Write the compaction summary in the user's preferred language(for example, if user prefers Chinese, then the compaction should be in Chinese as well).
|
|
9
|
+
All section titles, descriptions, analysis, and next-step suggestions should use the user's language.
|
|
10
|
+
Keep technical terms (filenames, variable names, commands, code snippets) in their original form.
|
|
11
|
+
`;
|
|
12
|
+
|
|
13
|
+
export const compactionPlugin: Plugin = async () => {
|
|
14
|
+
const config = await loadOcTweaksConfig();
|
|
15
|
+
if (!config || config.compaction?.enabled !== true) return {};
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
"experimental.session.compacting": safeHook(
|
|
19
|
+
"compaction",
|
|
20
|
+
async (
|
|
21
|
+
_input: { sessionID: string },
|
|
22
|
+
output: { context: string[]; prompt?: string },
|
|
23
|
+
) => {
|
|
24
|
+
output.context.push(LANGUAGE_PREFERENCE_PROMPT);
|
|
25
|
+
},
|
|
26
|
+
),
|
|
27
|
+
};
|
|
28
|
+
};
|