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 +21 -0
- package/README.md +120 -0
- package/package.json +49 -0
- package/src/config.ts +19 -0
- package/src/features/checkpoints.ts +127 -0
- package/src/features/guard.ts +227 -0
- package/src/features/index.ts +10 -0
- package/src/features/sandbox.ts +102 -0
- package/src/features/sched.ts +162 -0
- package/src/features/tasks.ts +345 -0
- package/src/features/usage.ts +270 -0
- package/src/index.ts +39 -0
- package/src/lib/busy.ts +25 -0
- package/src/lib/inject.ts +56 -0
- package/src/lib/probe.ts +15 -0
- package/src/lib/state.ts +27 -0
- package/src/merge.ts +35 -0
- package/src/tui.ts +227 -0
- package/src/types.ts +21 -0
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
|
+
}
|