opencode-overclock 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Thomas Caron
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,120 @@
1
+ # opencode-overclock
2
+
3
+ Power-ups for [opencode](https://opencode.ai). Background tasks, scheduling, sandboxed bash, tool hooks, usage telemetry, checkpoints. Each = module, toggleable. Module dies when opencode ships native equal/better.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ opencode plugin opencode-overclock # this project
9
+ opencode plugin -g opencode-overclock # global
10
+ ```
11
+
12
+ Requires opencode >= 1.18.9.
13
+
14
+ One package, two surfaces: the **server** plugin (tools + hooks) and the **TUI** plugin (notifications + slash commands). They register in _separate_ config files, so use the command above rather than editing config by hand — it writes both:
15
+
16
+ ```jsonc
17
+ // opencode.json -> server surface
18
+ { "plugin": ["opencode-overclock"] }
19
+ // tui.json -> TUI surface (omit this and notifications/slash commands silently never load)
20
+ { "plugin": ["opencode-overclock"] }
21
+ ```
22
+
23
+ Local dev: copy or symlink into `.opencode/plugins/` — see [Dev](#dev). Note that a plugin _path_ only works there; `plugin` array entries resolve by npm name from the registry, and an unpublished name fails silently.
24
+
25
+ ## Config
26
+
27
+ `.opencode/overclock.json` (optional, missing = defaults):
28
+
29
+ ```json
30
+ {
31
+ "features": {
32
+ "tasks": true,
33
+ "sched": true,
34
+ "sandbox": { "net": false },
35
+ "guard": {
36
+ "hooks": [{ "name": "typecheck", "tools": ["edit", "write"], "run": "bun run typecheck" }]
37
+ }
38
+ }
39
+ }
40
+ ```
41
+
42
+ `true`/`false` toggle. Object = on + options. Defaults: all on except sandbox; guard inert without `hooks`.
43
+
44
+ ## Features
45
+
46
+ | Module | Tools | Does |
47
+ | ------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
48
+ | `tasks` | `task_run` `task_status` `task_output` `task_kill` | background shell cmds; exit -> result posted back into session; stall watchdog nudges on interactive prompts |
49
+ | `sched` | `schedule_create` `schedule_list` `schedule_delete` | cron exprs or intervals ("5m"); interval + current session = loop; restart-safe |
50
+ | `sandbox` | `bash_unsandboxed` (escape hatch) | bwrap-wrap every bash call: `/` ro, project + `/tmp` rw, net configurable. Opt-in |
51
+ | `guard` | — | user hooks: after matching tool calls, run configured cmds (debounced), failures fed back to model |
52
+ | `usage` | `usage_report` | per-day + per-session cost/token telemetry off `message.updated` events |
53
+ | `checkpoints` | `checkpoint_list` `checkpoint_revert` `checkpoint_restore` | session revert/unrevert over opencode's shadow-git snapshots; revert gated by permission ask |
54
+
55
+ TUI plugin (`src/tui.ts`, separate surface): OS notifications on idle/permission/question/error via `attention.notify`, slash commands for tasks/usage/schedules off the `.opencode/overclock/` state mirrors.
56
+
57
+ ## Layout
58
+
59
+ ```
60
+ src/
61
+ index.ts entry: config -> init modules -> merge hooks
62
+ tui.ts TUI plugin (notifications + slash commands), separate export
63
+ types.ts FeatureModule contract
64
+ config.ts config loader
65
+ merge.ts hook composition (many modules, same hook -> sequential)
66
+ lib/ state dir + json, session inject + toast
67
+ features/ one file per module + registry
68
+ test/ bun test
69
+ ```
70
+
71
+ ## Add feature
72
+
73
+ 1. `src/features/<name>.ts`, export `FeatureModule`
74
+ 2. Register in `src/features/index.ts`
75
+
76
+ ## Docs
77
+
78
+ - [docs/opencode-plugin-surface.md](docs/opencode-plugin-surface.md) — opencode plugin/hook/event surface map + upstream drift watchlist (research)
79
+
80
+ ## Dev
81
+
82
+ ```sh
83
+ bun install
84
+ bun test # unit
85
+ bun run check # typecheck + format check
86
+ bun run format
87
+ bun run verify # packaging: tarball contents, server+tui targets, manifest metadata
88
+ ```
89
+
90
+ ### Release
91
+
92
+ ```sh
93
+ bun run check && bun test && bun run verify
94
+ npm publish
95
+ bun run verify:published # runtime load, by name, from the registry
96
+ ```
97
+
98
+ `verify` cannot exercise the runtime load path — opencode resolves `plugin` entries by npm
99
+ name from the registry, and a miss is silent. `verify:published` is the only check that
100
+ proves an installed-from-npm session actually gets the tools; run it after every publish.
101
+
102
+ ### Live loop
103
+
104
+ `.opencode/plugins/dev.ts` re-exports `src/index.ts`, `dev-tui.ts` re-exports `src/tui.ts` -> opencode session in this repo runs both surfaces from source.
105
+
106
+ 1. `opencode` here. Plugin live.
107
+ 2. Edit `src/`. No hot reload -> restart opencode.
108
+ 3. State inspect: `.opencode/overclock/` (gitignored).
109
+
110
+ ### Headless e2e
111
+
112
+ ```sh
113
+ timeout 90 opencode run -m anthropic/claude-sonnet-5 "Use task_run to run 'echo hi' ..." < /dev/null
114
+ ```
115
+
116
+ Gotchas:
117
+
118
+ - `< /dev/null` required. Open stdin -> hang.
119
+ - Dev build hang on exit AFTER work done -> wrap in `timeout`, judge by artifacts (`.opencode/overclock/`, log tails), not exit code.
120
+ - Plugin stderr: `opencode run --print-logs` or `~/.local/share/opencode/log/`. Grep `[overclock]`.
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "opencode-overclock",
3
+ "version": "0.1.0",
4
+ "description": "Power-ups for opencode: background tasks, scheduling, sandboxed bash, tool hooks, usage telemetry, checkpoints. Modular, toggleable.",
5
+ "type": "module",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/tomtom103/opencode-overclock.git"
9
+ },
10
+ "homepage": "https://github.com/tomtom103/opencode-overclock#readme",
11
+ "bugs": "https://github.com/tomtom103/opencode-overclock/issues",
12
+ "main": "src/index.ts",
13
+ "exports": {
14
+ ".": "./src/index.ts",
15
+ "./server": "./src/index.ts",
16
+ "./tui": "./src/tui.ts"
17
+ },
18
+ "files": [
19
+ "src"
20
+ ],
21
+ "keywords": [
22
+ "opencode",
23
+ "opencode-plugin",
24
+ "opencode-tui-plugin",
25
+ "agent",
26
+ "cli"
27
+ ],
28
+ "license": "MIT",
29
+ "scripts": {
30
+ "typecheck": "tsc --noEmit",
31
+ "format": "prettier --write .",
32
+ "check": "tsc --noEmit && prettier --check .",
33
+ "verify": "scripts/verify-pack.sh",
34
+ "verify:published": "scripts/verify-pack.sh --published",
35
+ "prepublishOnly": "tsc --noEmit && bun test"
36
+ },
37
+ "engines": {
38
+ "opencode": ">=1.18.9"
39
+ },
40
+ "dependencies": {
41
+ "@opencode-ai/plugin": "1.18.9",
42
+ "croner": "^10.0.1"
43
+ },
44
+ "devDependencies": {
45
+ "@types/bun": "latest",
46
+ "prettier": "^3.9.6",
47
+ "typescript": "^5.7.0"
48
+ }
49
+ }
package/src/config.ts ADDED
@@ -0,0 +1,19 @@
1
+ import type { OverclockConfig } from "./types.ts"
2
+
3
+ const CONFIG_PATHS = [".opencode/overclock.json", "overclock.json"]
4
+
5
+ /** Load plugin config from project dir. Missing file -> {} (all defaults). */
6
+ export async function loadConfig(directory: string): Promise<OverclockConfig> {
7
+ for (const rel of CONFIG_PATHS) {
8
+ const file = Bun.file(`${directory}/${rel}`)
9
+ if (await file.exists()) {
10
+ try {
11
+ return (await file.json()) as OverclockConfig
12
+ } catch (e) {
13
+ console.warn(`[overclock] bad config ${rel}: ${e}`)
14
+ return {}
15
+ }
16
+ }
17
+ }
18
+ return {}
19
+ }
@@ -0,0 +1,127 @@
1
+ import { tool, type PluginInput } from "@opencode-ai/plugin"
2
+ import type { FeatureModule } from "../types.ts"
3
+
4
+ const z = tool.schema
5
+
6
+ type Client = PluginInput["client"]
7
+
8
+ interface MessagePart {
9
+ type: string
10
+ text?: string
11
+ }
12
+
13
+ interface MessageEntry {
14
+ info: {
15
+ id: string
16
+ role: string
17
+ time?: { created?: number }
18
+ }
19
+ parts: MessagePart[]
20
+ }
21
+
22
+ const collapse = (s: string) => s.replace(/\s+/g, " ").trim()
23
+
24
+ /** Exported for tests: revert/unrevert/list core, decoupled from plugin ctx. */
25
+ export interface Checkpoints {
26
+ list(sessionID: string): Promise<string>
27
+ revert(sessionID: string, messageID: string): Promise<string>
28
+ restore(sessionID: string): Promise<string>
29
+ }
30
+
31
+ export function createCheckpoints(client: Client): Checkpoints {
32
+ async function list(sessionID: string): Promise<string> {
33
+ try {
34
+ const res = await client.session.messages({ path: { id: sessionID } })
35
+ const msgs = ((res.data ?? []) as MessageEntry[]).filter((m) => m.info.role === "user")
36
+ if (!msgs.length) return "no checkpoints"
37
+ return msgs
38
+ .map((m) => {
39
+ const time = m.info.time?.created
40
+ ? new Date(m.info.time.created).toISOString()
41
+ : "unknown time"
42
+ const text = m.parts.find((p) => p.type === "text" && typeof p.text === "string")?.text ?? ""
43
+ const preview = collapse(text).slice(0, 60)
44
+ return `${m.info.id} ${time} ${preview}`
45
+ })
46
+ .join("\n")
47
+ } catch (e) {
48
+ console.warn(`[overclock] checkpoints: list failed (session ${sessionID}): ${e}`)
49
+ return `error listing checkpoints: ${e}`
50
+ }
51
+ }
52
+
53
+ async function revert(sessionID: string, messageID: string): Promise<string> {
54
+ try {
55
+ await client.session.revert({ path: { id: sessionID }, body: { messageID } })
56
+ return `reverted session ${sessionID} to before message ${messageID}`
57
+ } catch (e) {
58
+ console.warn(
59
+ `[overclock] checkpoints: revert failed (session ${sessionID}, message ${messageID}): ${e}`,
60
+ )
61
+ return `error reverting checkpoint: ${e}`
62
+ }
63
+ }
64
+
65
+ async function restore(sessionID: string): Promise<string> {
66
+ try {
67
+ await client.session.unrevert({ path: { id: sessionID } })
68
+ return `restored session ${sessionID} to latest (undo revert)`
69
+ } catch (e) {
70
+ console.warn(`[overclock] checkpoints: restore failed (session ${sessionID}): ${e}`)
71
+ return `error restoring checkpoint: ${e}`
72
+ }
73
+ }
74
+
75
+ return { list, revert, restore }
76
+ }
77
+
78
+ /**
79
+ * Shadow-git revert tools (map doc: "Checkpoints — none gap"). Wraps native
80
+ * session.revert/unrevert around each user message as a revert point.
81
+ */
82
+ export const checkpoints: FeatureModule = {
83
+ name: "checkpoints",
84
+ defaultEnabled: true,
85
+ requires: ["session.revert", "session.unrevert", "session.messages"],
86
+ async init(ctx) {
87
+ const core = createCheckpoints(ctx.client)
88
+
89
+ return {
90
+ tool: {
91
+ checkpoint_list: tool({
92
+ description:
93
+ "List user messages of a session as revert points: messageID, time, first 60 chars. Most recent last.",
94
+ args: { sessionID: z.string().optional().describe("default: current session") },
95
+ async execute(args, tctx) {
96
+ return core.list(args.sessionID ?? tctx.sessionID)
97
+ },
98
+ }),
99
+ checkpoint_revert: tool({
100
+ description:
101
+ "Revert session files + conversation back to before the given message (shadow-git, reversible via checkpoint_restore). Requires user permission.",
102
+ args: {
103
+ messageID: z.string(),
104
+ sessionID: z.string().optional().describe("default: current session"),
105
+ },
106
+ async execute(args, tctx) {
107
+ const sessionID = args.sessionID ?? tctx.sessionID
108
+ await tctx.ask({
109
+ permission: "checkpoint_revert",
110
+ patterns: [args.messageID],
111
+ always: [],
112
+ metadata: { sessionID, messageID: args.messageID },
113
+ })
114
+ return core.revert(sessionID, args.messageID)
115
+ },
116
+ }),
117
+ checkpoint_restore: tool({
118
+ description: "Undo the most recent checkpoint_revert for a session.",
119
+ args: { sessionID: z.string().optional().describe("default: current session") },
120
+ async execute(args, tctx) {
121
+ return core.restore(args.sessionID ?? tctx.sessionID)
122
+ },
123
+ }),
124
+ },
125
+ }
126
+ },
127
+ }
@@ -0,0 +1,227 @@
1
+ import type { FeatureModule } from "../types.ts"
2
+ import { inject, toast } from "../lib/inject.ts"
3
+
4
+ export interface GuardHook {
5
+ name: string
6
+ tools: string[]
7
+ pathFilter?: string
8
+ run: string
9
+ mode: "inject" | "append"
10
+ debounceMs: number
11
+ timeoutMs: number
12
+ onSuccess: "silent" | "notify"
13
+ }
14
+
15
+ /** options.hooks -> validated GuardHook[]. Invalid entries -> console.warn, skipped, never throw. */
16
+ export function parseHooks(raw: unknown): GuardHook[] {
17
+ if (!Array.isArray(raw)) return []
18
+ const hooks: GuardHook[] = []
19
+ for (const entry of raw) {
20
+ const e = (entry ?? {}) as Record<string, unknown>
21
+ const validTools =
22
+ Array.isArray(e.tools) && e.tools.length > 0 && e.tools.every((t) => typeof t === "string")
23
+ if (typeof e.name !== "string" || !e.name || !validTools || typeof e.run !== "string" || !e.run) {
24
+ console.warn(`[overclock] guard: skipping invalid hook config: ${JSON.stringify(entry)}`)
25
+ continue
26
+ }
27
+ hooks.push({
28
+ name: e.name,
29
+ tools: e.tools as string[],
30
+ pathFilter: typeof e.pathFilter === "string" ? e.pathFilter : undefined,
31
+ run: e.run,
32
+ mode: e.mode === "append" ? "append" : "inject",
33
+ debounceMs: typeof e.debounceMs === "number" ? e.debounceMs : 2000,
34
+ timeoutMs: typeof e.timeoutMs === "number" ? e.timeoutMs : 60000,
35
+ onSuccess: e.onSuccess === "notify" ? "notify" : "silent",
36
+ })
37
+ }
38
+ return hooks
39
+ }
40
+
41
+ /** tools: exact match. pathFilter set + no filePath -> no match. */
42
+ export function matchHook(hook: GuardHook, toolName: string, filePath: string | undefined): boolean {
43
+ if (!hook.tools.includes(toolName)) return false
44
+ if (!hook.pathFilter) return true
45
+ if (typeof filePath !== "string") return false
46
+ return new Bun.Glob(hook.pathFilter).match(filePath)
47
+ }
48
+
49
+ /** `[guard "<name>" failed (exit <code>)]` + last 40 lines of combined stdout+stderr. */
50
+ export function failurePayload(name: string, code: number | null, combined: string): string {
51
+ const tail = combined.split("\n").slice(-40).join("\n")
52
+ return `\n\n[guard "${name}" failed (exit ${code})]\n${tail}`
53
+ }
54
+
55
+ function buildEnv(toolName: string, filePath: string | undefined): Record<string, string | undefined> {
56
+ return {
57
+ ...process.env,
58
+ GUARD_TOOL: toolName,
59
+ ...(filePath !== undefined ? { GUARD_FILE: filePath } : {}),
60
+ }
61
+ }
62
+
63
+ async function runCommand(
64
+ hook: GuardHook,
65
+ cwd: string,
66
+ env: Record<string, string | undefined>,
67
+ register?: (proc: Bun.Subprocess) => void,
68
+ ): Promise<{ code: number | null; combined: string }> {
69
+ const proc = Bun.spawn(["bash", "-c", hook.run], { cwd, env, stdout: "pipe", stderr: "pipe" })
70
+ register?.(proc)
71
+ const killTimer = setTimeout(() => proc.kill("SIGTERM"), hook.timeoutMs)
72
+ const [out, err, code] = await Promise.all([
73
+ new Response(proc.stdout).text(),
74
+ new Response(proc.stderr).text(),
75
+ proc.exited,
76
+ ])
77
+ clearTimeout(killTimer)
78
+ return { code, combined: out + err }
79
+ }
80
+
81
+ interface HookState {
82
+ timer?: ReturnType<typeof setTimeout>
83
+ running: boolean
84
+ lastSessionID?: string
85
+ lastToolName: string
86
+ lastFilePath?: string
87
+ procs: Set<Bun.Subprocess>
88
+ }
89
+
90
+ export interface GuardRunnerDeps {
91
+ cwd: string
92
+ onInject: (sessionID: string, payload: string) => Promise<void>
93
+ onNotify: (hookName: string) => Promise<void>
94
+ }
95
+
96
+ export interface GuardRunner {
97
+ /** append mode: run synchronously, return failure payload (undefined on success). */
98
+ runAppend(hook: GuardHook, toolName: string, filePath: string | undefined): Promise<string | undefined>
99
+ /** inject mode: (re)arm the trailing debounce for this hook. */
100
+ triggerInject(hook: GuardHook, sessionID: string, toolName: string, filePath: string | undefined): void
101
+ dispose(): void
102
+ }
103
+
104
+ /** Exported for tests: debounce/run core decoupled from plugin ctx. */
105
+ export function createGuardRunner(deps: GuardRunnerDeps): GuardRunner {
106
+ const states = new Map<string, HookState>()
107
+
108
+ function state(name: string): HookState {
109
+ let s = states.get(name)
110
+ if (!s) {
111
+ s = { running: false, lastToolName: "", procs: new Set() }
112
+ states.set(name, s)
113
+ }
114
+ return s
115
+ }
116
+
117
+ async function runAppend(
118
+ hook: GuardHook,
119
+ toolName: string,
120
+ filePath: string | undefined,
121
+ ): Promise<string | undefined> {
122
+ const s = state(hook.name)
123
+ const result = await runCommand(hook, deps.cwd, buildEnv(toolName, filePath), (p) => s.procs.add(p))
124
+ s.procs.clear()
125
+ if (result.code !== 0) return failurePayload(hook.name, result.code, result.combined)
126
+ if (hook.onSuccess === "notify") await deps.onNotify(hook.name)
127
+ return undefined
128
+ }
129
+
130
+ async function fire(hook: GuardHook): Promise<void> {
131
+ const s = state(hook.name)
132
+ if (s.running) {
133
+ // still mid-run: skip this launch, re-arm the trailing debounce
134
+ s.timer = setTimeout(() => void fire(hook), hook.debounceMs)
135
+ return
136
+ }
137
+ s.running = true
138
+ const sessionID = s.lastSessionID
139
+ const toolName = s.lastToolName
140
+ const filePath = s.lastFilePath
141
+ try {
142
+ const result = await runCommand(hook, deps.cwd, buildEnv(toolName, filePath), (p) =>
143
+ s.procs.add(p),
144
+ )
145
+ if (result.code !== 0) {
146
+ if (sessionID)
147
+ await deps.onInject(sessionID, failurePayload(hook.name, result.code, result.combined))
148
+ } else if (hook.onSuccess === "notify") {
149
+ await deps.onNotify(hook.name)
150
+ }
151
+ } finally {
152
+ s.running = false
153
+ s.procs.clear()
154
+ }
155
+ }
156
+
157
+ function triggerInject(
158
+ hook: GuardHook,
159
+ sessionID: string,
160
+ toolName: string,
161
+ filePath: string | undefined,
162
+ ): void {
163
+ const s = state(hook.name)
164
+ s.lastSessionID = sessionID
165
+ s.lastToolName = toolName
166
+ s.lastFilePath = filePath
167
+ if (s.timer) clearTimeout(s.timer)
168
+ s.timer = setTimeout(() => void fire(hook), hook.debounceMs)
169
+ }
170
+
171
+ function dispose(): void {
172
+ for (const s of states.values()) {
173
+ if (s.timer) clearTimeout(s.timer)
174
+ for (const p of s.procs) p.kill("SIGTERM")
175
+ }
176
+ }
177
+
178
+ return { runAppend, triggerInject, dispose }
179
+ }
180
+
181
+ /**
182
+ * User-configurable post-tool hooks. Run commands after matching tool calls. No config -> inert.
183
+ * "append": run synchronously, failure appended to tool output in place.
184
+ * "inject" (default): debounced per hook name, failure injected as a user turn.
185
+ */
186
+ export const guard: FeatureModule = {
187
+ name: "guard",
188
+ defaultEnabled: true,
189
+ requires: ["session.promptAsync", "session.messages"],
190
+ async init(ctx, options) {
191
+ if (options.hooks !== undefined && !Array.isArray(options.hooks)) {
192
+ console.warn(`[overclock] guard: options.hooks must be an array, got ${typeof options.hooks}`)
193
+ }
194
+ const hooks = parseHooks(options.hooks)
195
+ if (hooks.length === 0) return {}
196
+
197
+ const runner = createGuardRunner({
198
+ cwd: ctx.directory,
199
+ onInject: async (sessionID, payload) => {
200
+ await inject(ctx.client, sessionID, payload)
201
+ },
202
+ onNotify: async (hookName) => {
203
+ await toast(ctx.client, `guard "${hookName}" passed`, "success")
204
+ },
205
+ })
206
+
207
+ return {
208
+ dispose: async () => {
209
+ runner.dispose()
210
+ },
211
+ "tool.execute.after": async (input, output) => {
212
+ const args = input.args as Record<string, unknown> | undefined
213
+ const filePath = typeof args?.filePath === "string" ? args.filePath : undefined
214
+
215
+ for (const hook of hooks) {
216
+ if (!matchHook(hook, input.tool, filePath)) continue
217
+ if (hook.mode === "append") {
218
+ const payload = await runner.runAppend(hook, input.tool, filePath)
219
+ if (payload && typeof output.output === "string") output.output += payload
220
+ } else {
221
+ runner.triggerInject(hook, input.sessionID, input.tool, filePath)
222
+ }
223
+ }
224
+ },
225
+ }
226
+ },
227
+ }
@@ -0,0 +1,10 @@
1
+ import type { FeatureModule } from "../types.ts"
2
+ import { tasks } from "./tasks.ts"
3
+ import { sched } from "./sched.ts"
4
+ import { sandbox } from "./sandbox.ts"
5
+ import { guard } from "./guard.ts"
6
+ import { usage } from "./usage.ts"
7
+ import { checkpoints } from "./checkpoints.ts"
8
+
9
+ /** Registry, ordered. Order = hook composition order. */
10
+ export const features: FeatureModule[] = [tasks, sched, sandbox, guard, usage, checkpoints]
@@ -0,0 +1,102 @@
1
+ import { tool } from "@opencode-ai/plugin"
2
+ import type { FeatureModule } from "../types.ts"
3
+ import { shellQuote } from "../lib/state.ts"
4
+
5
+ const z = tool.schema
6
+
7
+ export interface SandboxPolicy {
8
+ project: string
9
+ net: boolean
10
+ }
11
+
12
+ /** Pure: wrap shell cmd in bwrap. / ro, project + /tmp rw, net per policy. */
13
+ export function wrapCommand(cmd: string, policy: SandboxPolicy): string {
14
+ const args = [
15
+ "bwrap",
16
+ "--ro-bind / /",
17
+ "--dev /dev",
18
+ "--proc /proc",
19
+ `--bind ${shellQuote(policy.project)} ${shellQuote(policy.project)}`,
20
+ "--bind /tmp /tmp",
21
+ "--die-with-parent",
22
+ ]
23
+ if (!policy.net) args.push("--unshare-net")
24
+ args.push("bash -c", shellQuote(cmd))
25
+ return args.join(" ")
26
+ }
27
+
28
+ /** Functional probe: bwrap present AND userns allowed (WSL2/distros vary). */
29
+ export function probeBwrap(): boolean {
30
+ try {
31
+ const res = Bun.spawnSync([
32
+ "bwrap",
33
+ "--ro-bind",
34
+ "/",
35
+ "/",
36
+ "--dev",
37
+ "/dev",
38
+ "--proc",
39
+ "/proc",
40
+ "true",
41
+ ])
42
+ return res.exitCode === 0
43
+ } catch {
44
+ return false
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Sandboxed bash via bubblewrap. Rewrites every bash tool call.
50
+ * Off by default. No bwrap -> warn once, passthrough.
51
+ */
52
+ export const sandbox: FeatureModule = {
53
+ name: "sandbox",
54
+ defaultEnabled: false,
55
+ async init(ctx, options) {
56
+ const policy: SandboxPolicy = {
57
+ project: ctx.directory,
58
+ net: options.net !== false,
59
+ }
60
+ const available = probeBwrap()
61
+ if (!available) console.warn("[overclock] sandbox: bwrap unavailable/blocked -> passthrough")
62
+
63
+ return {
64
+ "tool.execute.before": async (input, output) => {
65
+ if (!available || input.tool !== "bash") return
66
+ const cmd = (output.args as { command?: string }).command
67
+ if (typeof cmd !== "string") return
68
+ output.args.command = wrapCommand(cmd, policy)
69
+ },
70
+ tool: {
71
+ bash_unsandboxed: tool({
72
+ description:
73
+ "Run a shell command OUTSIDE the sandbox (full FS write access). Requires user permission. Use only when the sandbox blocks a legitimate operation.",
74
+ args: {
75
+ command: z.string(),
76
+ cwd: z.string().optional(),
77
+ },
78
+ async execute(args, tctx) {
79
+ await tctx.ask({
80
+ permission: "bash_unsandboxed",
81
+ patterns: [args.command],
82
+ always: [],
83
+ metadata: { command: args.command },
84
+ })
85
+ const proc = Bun.spawn(["bash", "-c", args.command], {
86
+ cwd: args.cwd ?? tctx.directory,
87
+ stdout: "pipe",
88
+ stderr: "pipe",
89
+ })
90
+ const [out, err, code] = await Promise.all([
91
+ new Response(proc.stdout).text(),
92
+ new Response(proc.stderr).text(),
93
+ proc.exited,
94
+ ])
95
+ const text = (out + (err ? `\nstderr:\n${err}` : "")).slice(0, 30_000)
96
+ return `exit ${code}\n${text}`
97
+ },
98
+ }),
99
+ },
100
+ }
101
+ },
102
+ }