opencode-overclock 0.3.0 → 0.4.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 +206 -111
- package/package.json +2 -2
- package/src/bridge.ts +1 -0
- package/src/buddy/companion.ts +104 -5
- package/src/buddy/sprites.ts +4 -4
- package/src/buddy/tui.ts +175 -65
- package/src/core/bridge.ts +34 -0
- package/src/core/lifecycle.ts +53 -0
- package/src/core/policy.ts +128 -0
- package/src/core/summary.ts +33 -0
- package/src/core/types.ts +164 -0
- package/src/features/buddy.ts +1 -2
- package/src/features/guard.ts +168 -30
- package/src/features/index.ts +6 -4
- package/src/features/recovery.ts +143 -0
- package/src/features/sched.ts +147 -89
- package/src/features/tasks.ts +52 -18
- package/src/features/truncator.ts +99 -0
- package/src/features/usage.ts +26 -65
- package/src/index.ts +96 -67
- package/src/lib/busy.ts +1 -25
- package/src/lib/exec.ts +7 -0
- package/src/lib/inject.ts +10 -56
- package/src/lib/mirror.ts +13 -0
- package/src/lib/probe.ts +1 -15
- package/src/lib/state.ts +10 -39
- package/src/lib/tmux.ts +1 -0
- package/src/lib/ui.ts +208 -0
- package/src/merge.ts +2 -66
- package/src/platform/probe.ts +25 -0
- package/src/platform/process/exec.ts +76 -0
- package/src/platform/process/tmux.ts +60 -0
- package/src/platform/session/busy.ts +33 -0
- package/src/platform/session/inject.ts +82 -0
- package/src/platform/session/notify.ts +20 -0
- package/src/platform/storage/state.ts +77 -0
- package/src/platform/storage/store.ts +61 -0
- package/src/summary.ts +1 -0
- package/src/tools.ts +8 -244
- package/src/tui.ts +57 -186
- package/src/types.ts +1 -73
- package/src/v2/context.ts +470 -0
- package/src/v2/host.ts +117 -0
- package/src/v2/loader.ts +150 -0
- package/src/buddy/reactions.ts +0 -41
- package/src/buddy/types.ts +0 -30
- package/src/config.ts +0 -19
- package/src/features/checkpoints.ts +0 -128
- package/src/features/sandbox.ts +0 -104
- package/src/validate.ts +0 -197
package/src/v2/loader.ts
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview OpenCode V2 Plugin Resolution and Loading
|
|
3
|
+
*
|
|
4
|
+
* Provides resolution, dynamic importation, and execution of V2 plugins
|
|
5
|
+
* on top of a synthetic V2 `PluginContext`.
|
|
6
|
+
*
|
|
7
|
+
* Supported plugin formats:
|
|
8
|
+
* - Direct instances: `{ id, setup(context) }` or `{ id, effect(context) }`
|
|
9
|
+
* - File paths: `./plugins/custom.ts`, `/absolute/path/plugin.js`, `file:///...`
|
|
10
|
+
* - Npm packages: bare module specifiers resolved via node/bun module resolution
|
|
11
|
+
* - Tuples: `[specifier, pluginOptions]` for supplying per-plugin options
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { resolve, isAbsolute } from "path"
|
|
15
|
+
import { pathToFileURL, fileURLToPath } from "url"
|
|
16
|
+
import type { Plugin as V2Plugin, PluginOptions } from "@opencode-ai/plugin/v2/promise"
|
|
17
|
+
import type { Disposer, V2ContextHandle } from "./context.ts"
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Union of accepted V2 plugin declaration formats:
|
|
21
|
+
* - String specifier: file path or package name
|
|
22
|
+
* - Tuple: `[specifier, options]`
|
|
23
|
+
* - Plugin instance conforming to V2 interface
|
|
24
|
+
* - Wrapper object: `{ plugin, options }`
|
|
25
|
+
*/
|
|
26
|
+
export type V2PluginSpec =
|
|
27
|
+
string | [string, PluginOptions] | V2Plugin | { plugin: V2Plugin; options?: PluginOptions }
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Validates whether an unknown value conforms to the OpenCode V2 plugin contract:
|
|
31
|
+
* requires non-empty string `id`, and either an async `setup` method or an `effect` function.
|
|
32
|
+
*/
|
|
33
|
+
export function isV2Plugin(value: unknown): value is V2Plugin {
|
|
34
|
+
if (!value || typeof value !== "object") return false
|
|
35
|
+
const p = value as Record<string, unknown>
|
|
36
|
+
if (typeof p.id !== "string" || !p.id.trim()) return false
|
|
37
|
+
return typeof p.setup === "function" || typeof (p as any).effect === "function"
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Resolves a file path or URL specifier against the workspace directory.
|
|
42
|
+
* Preserves bare package names for standard Node/Bun module resolution.
|
|
43
|
+
*/
|
|
44
|
+
export function resolvePluginPath(spec: string, baseDir: string): string {
|
|
45
|
+
if (spec.startsWith("file://")) return fileURLToPath(spec)
|
|
46
|
+
if (isAbsolute(spec)) return spec
|
|
47
|
+
if (spec.startsWith(".")) return resolve(baseDir, spec)
|
|
48
|
+
return spec
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Resolves a V2 plugin specifier into a concrete V2Plugin object and its associated options.
|
|
53
|
+
* Dynamically imports file paths or npm packages if necessary.
|
|
54
|
+
*/
|
|
55
|
+
export async function resolveV2Plugin(
|
|
56
|
+
spec: V2PluginSpec,
|
|
57
|
+
baseDir: string,
|
|
58
|
+
): Promise<{ plugin: V2Plugin; options: PluginOptions } | null> {
|
|
59
|
+
// Case 1: Already an instantiated V2Plugin object
|
|
60
|
+
if (isV2Plugin(spec)) {
|
|
61
|
+
return { plugin: spec, options: {} }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Case 2: Wrapped { plugin, options } object
|
|
65
|
+
if (
|
|
66
|
+
typeof spec === "object" &&
|
|
67
|
+
spec !== null &&
|
|
68
|
+
"plugin" in spec &&
|
|
69
|
+
isV2Plugin((spec as any).plugin)
|
|
70
|
+
) {
|
|
71
|
+
return {
|
|
72
|
+
plugin: (spec as any).plugin,
|
|
73
|
+
options: (spec as any).options ?? {},
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Case 3: Specifier string or [string, options] tuple
|
|
78
|
+
let moduleSpec: string
|
|
79
|
+
let options: PluginOptions = {}
|
|
80
|
+
|
|
81
|
+
if (Array.isArray(spec)) {
|
|
82
|
+
moduleSpec = spec[0]
|
|
83
|
+
options = spec[1] ?? {}
|
|
84
|
+
} else if (typeof spec === "string") {
|
|
85
|
+
moduleSpec = spec
|
|
86
|
+
} else {
|
|
87
|
+
return null
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const resolved = resolvePluginPath(moduleSpec, baseDir)
|
|
91
|
+
const importTarget = resolved.startsWith("/") ? pathToFileURL(resolved).href : resolved
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
const mod = await import(importTarget)
|
|
95
|
+
const candidate = mod?.default ?? mod
|
|
96
|
+
if (isV2Plugin(candidate)) {
|
|
97
|
+
return { plugin: candidate, options }
|
|
98
|
+
}
|
|
99
|
+
// Check named exports for a V2 plugin definition
|
|
100
|
+
for (const val of Object.values(mod)) {
|
|
101
|
+
if (isV2Plugin(val)) {
|
|
102
|
+
return { plugin: val, options }
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
console.warn(
|
|
106
|
+
`[overclock] v2: module '${moduleSpec}' does not export a valid V2 plugin ({ id, setup/effect })`,
|
|
107
|
+
)
|
|
108
|
+
return null
|
|
109
|
+
} catch (e) {
|
|
110
|
+
console.warn(`[overclock] v2: failed to import plugin '${moduleSpec}': ${e}`)
|
|
111
|
+
return null
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Loads and initializes a V2 plugin against the synthetic context.
|
|
117
|
+
* Creates a scoped context, handles Effect vs Promise lifecycle, and tracks disposers.
|
|
118
|
+
* Returns the plugin ID if loaded successfully, or null on error.
|
|
119
|
+
*/
|
|
120
|
+
export async function loadV2Plugin(
|
|
121
|
+
spec: V2PluginSpec,
|
|
122
|
+
baseDir: string,
|
|
123
|
+
handle: V2ContextHandle,
|
|
124
|
+
): Promise<string | null> {
|
|
125
|
+
const resolved = await resolveV2Plugin(spec, baseDir)
|
|
126
|
+
if (!resolved) return null
|
|
127
|
+
|
|
128
|
+
const { plugin, options } = resolved
|
|
129
|
+
const pluginDisposers = new Set<Disposer>()
|
|
130
|
+
const scopedCtx = handle.scopedContext(options, pluginDisposers)
|
|
131
|
+
|
|
132
|
+
handle.state.activePlugins.set(plugin.id, {
|
|
133
|
+
plugin,
|
|
134
|
+
disposers: pluginDisposers,
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
if (typeof (plugin as any).effect === "function") {
|
|
139
|
+
const { runPromise } = await import("effect/Effect")
|
|
140
|
+
await runPromise((plugin as any).effect(scopedCtx))
|
|
141
|
+
} else if (typeof plugin.setup === "function") {
|
|
142
|
+
await plugin.setup(scopedCtx)
|
|
143
|
+
}
|
|
144
|
+
return plugin.id
|
|
145
|
+
} catch (e) {
|
|
146
|
+
console.warn(`[overclock] v2: plugin '${plugin.id}' failed during setup: ${e}`)
|
|
147
|
+
await handle.context.plugin.remove(plugin.id)
|
|
148
|
+
return null
|
|
149
|
+
}
|
|
150
|
+
}
|
package/src/buddy/reactions.ts
DELETED
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
import type { SpriteState } from "./sprites.ts"
|
|
2
|
-
|
|
3
|
-
export type ReactionKind = "done" | "error" | "permission" | "question" | "pet"
|
|
4
|
-
|
|
5
|
-
/** Speech-bubble line + which face the sprite pulls while it shows. */
|
|
6
|
-
export interface Reaction {
|
|
7
|
-
text: string
|
|
8
|
-
state: SpriteState
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
// Lines render into the sprite's 12-col effect row -- keep every line <= 12 chars.
|
|
12
|
-
const POOLS: Record<ReactionKind, { lines: string[]; state: SpriteState }> = {
|
|
13
|
-
done: { lines: ["done!", "all set.", "ship it.", "*stretch*"], state: "idle" },
|
|
14
|
-
error: { lines: ["uh oh.", "*winces*", "yikes."], state: "alarmed" },
|
|
15
|
-
permission: { lines: ["can we?", "*peeks*", "please?"], state: "curious" },
|
|
16
|
-
question: { lines: ["your call.", "hmm?", "*head tilt*"], state: "curious" },
|
|
17
|
-
pet: { lines: ["<3", "*purrs*", "hi!!", "missed you."], state: "pet" },
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/** Pure: pick a random line for a reaction kind. */
|
|
21
|
-
export function pickReaction(kind: ReactionKind, rng: () => number = Math.random): Reaction {
|
|
22
|
-
const pool = POOLS[kind]
|
|
23
|
-
return { text: pool.lines[Math.floor(rng() * pool.lines.length)]!, state: pool.state }
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export interface ReactionGate {
|
|
27
|
-
/** True + arms the cooldown if enough time has passed since the last fire. */
|
|
28
|
-
tryFire(now?: number): boolean
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/** Debounce for event-driven reactions -- keeps a busy session from spamming the bubble. */
|
|
32
|
-
export function createReactionGate(cooldownMs = 8000): ReactionGate {
|
|
33
|
-
let last = -Infinity
|
|
34
|
-
return {
|
|
35
|
-
tryFire(now: number = Date.now()): boolean {
|
|
36
|
-
if (now - last < cooldownMs) return false
|
|
37
|
-
last = now
|
|
38
|
-
return true
|
|
39
|
-
},
|
|
40
|
-
}
|
|
41
|
-
}
|
package/src/buddy/types.ts
DELETED
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
export const SPECIES = [
|
|
2
|
-
"cat",
|
|
3
|
-
"dog",
|
|
4
|
-
"bunny",
|
|
5
|
-
"owl",
|
|
6
|
-
"bat",
|
|
7
|
-
"penguin",
|
|
8
|
-
"duck",
|
|
9
|
-
"ghost",
|
|
10
|
-
"slime",
|
|
11
|
-
] as const
|
|
12
|
-
export type Species = (typeof SPECIES)[number]
|
|
13
|
-
|
|
14
|
-
export type Rarity = "common" | "uncommon" | "rare" | "legendary"
|
|
15
|
-
|
|
16
|
-
export interface CompanionStats {
|
|
17
|
-
patience: number
|
|
18
|
-
chaos: number
|
|
19
|
-
wisdom: number
|
|
20
|
-
snark: number
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/** Persisted in TUI kv. Rolled once at hatch, then stable for the life of the install. */
|
|
24
|
-
export interface Companion {
|
|
25
|
-
species: Species
|
|
26
|
-
rarity: Rarity
|
|
27
|
-
name: string
|
|
28
|
-
stats: CompanionStats
|
|
29
|
-
hatchedAt: number
|
|
30
|
-
}
|
package/src/config.ts
DELETED
|
@@ -1,19 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,128 +0,0 @@
|
|
|
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
|
-
tools: ["checkpoint_list", "checkpoint_revert", "checkpoint_restore"],
|
|
85
|
-
defaultEnabled: true,
|
|
86
|
-
requires: ["session.revert", "session.unrevert", "session.messages"],
|
|
87
|
-
async init(ctx) {
|
|
88
|
-
const core = createCheckpoints(ctx.client)
|
|
89
|
-
|
|
90
|
-
return {
|
|
91
|
-
tool: {
|
|
92
|
-
checkpoint_list: tool({
|
|
93
|
-
description:
|
|
94
|
-
"List user messages of a session as revert points: messageID, time, first 60 chars. Most recent last.",
|
|
95
|
-
args: { sessionID: z.string().optional().describe("default: current session") },
|
|
96
|
-
async execute(args, tctx) {
|
|
97
|
-
return core.list(args.sessionID ?? tctx.sessionID)
|
|
98
|
-
},
|
|
99
|
-
}),
|
|
100
|
-
checkpoint_revert: tool({
|
|
101
|
-
description:
|
|
102
|
-
"Revert session files + conversation back to before the given message (shadow-git, reversible via checkpoint_restore). Requires user permission.",
|
|
103
|
-
args: {
|
|
104
|
-
messageID: z.string(),
|
|
105
|
-
sessionID: z.string().optional().describe("default: current session"),
|
|
106
|
-
},
|
|
107
|
-
async execute(args, tctx) {
|
|
108
|
-
const sessionID = args.sessionID ?? tctx.sessionID
|
|
109
|
-
await tctx.ask({
|
|
110
|
-
permission: "checkpoint_revert",
|
|
111
|
-
patterns: [args.messageID],
|
|
112
|
-
always: [],
|
|
113
|
-
metadata: { sessionID, messageID: args.messageID },
|
|
114
|
-
})
|
|
115
|
-
return core.revert(sessionID, args.messageID)
|
|
116
|
-
},
|
|
117
|
-
}),
|
|
118
|
-
checkpoint_restore: tool({
|
|
119
|
-
description: "Undo the most recent checkpoint_revert for a session.",
|
|
120
|
-
args: { sessionID: z.string().optional().describe("default: current session") },
|
|
121
|
-
async execute(args, tctx) {
|
|
122
|
-
return core.restore(args.sessionID ?? tctx.sessionID)
|
|
123
|
-
},
|
|
124
|
-
}),
|
|
125
|
-
},
|
|
126
|
-
}
|
|
127
|
-
},
|
|
128
|
-
}
|
package/src/features/sandbox.ts
DELETED
|
@@ -1,104 +0,0 @@
|
|
|
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
|
-
tools: ["bash_unsandboxed"],
|
|
55
|
-
options: { net: "boolean" },
|
|
56
|
-
defaultEnabled: false,
|
|
57
|
-
async init(ctx, options) {
|
|
58
|
-
const policy: SandboxPolicy = {
|
|
59
|
-
project: ctx.directory,
|
|
60
|
-
net: options.net !== false,
|
|
61
|
-
}
|
|
62
|
-
const available = probeBwrap()
|
|
63
|
-
if (!available) console.warn("[overclock] sandbox: bwrap unavailable/blocked -> passthrough")
|
|
64
|
-
|
|
65
|
-
return {
|
|
66
|
-
"tool.execute.before": async (input, output) => {
|
|
67
|
-
if (!available || input.tool !== "bash") return
|
|
68
|
-
const cmd = (output.args as { command?: string }).command
|
|
69
|
-
if (typeof cmd !== "string") return
|
|
70
|
-
output.args.command = wrapCommand(cmd, policy)
|
|
71
|
-
},
|
|
72
|
-
tool: {
|
|
73
|
-
bash_unsandboxed: tool({
|
|
74
|
-
description:
|
|
75
|
-
"Run a shell command OUTSIDE the sandbox (full FS write access). Requires user permission. Use only when the sandbox blocks a legitimate operation.",
|
|
76
|
-
args: {
|
|
77
|
-
command: z.string(),
|
|
78
|
-
cwd: z.string().optional(),
|
|
79
|
-
},
|
|
80
|
-
async execute(args, tctx) {
|
|
81
|
-
await tctx.ask({
|
|
82
|
-
permission: "bash_unsandboxed",
|
|
83
|
-
patterns: [args.command],
|
|
84
|
-
always: [],
|
|
85
|
-
metadata: { command: args.command },
|
|
86
|
-
})
|
|
87
|
-
const proc = Bun.spawn(["bash", "-c", args.command], {
|
|
88
|
-
cwd: args.cwd ?? tctx.directory,
|
|
89
|
-
stdout: "pipe",
|
|
90
|
-
stderr: "pipe",
|
|
91
|
-
})
|
|
92
|
-
const [out, err, code] = await Promise.all([
|
|
93
|
-
new Response(proc.stdout).text(),
|
|
94
|
-
new Response(proc.stderr).text(),
|
|
95
|
-
proc.exited,
|
|
96
|
-
])
|
|
97
|
-
const text = (out + (err ? `\nstderr:\n${err}` : "")).slice(0, 30_000)
|
|
98
|
-
return `exit ${code}\n${text}`
|
|
99
|
-
},
|
|
100
|
-
}),
|
|
101
|
-
},
|
|
102
|
-
}
|
|
103
|
-
},
|
|
104
|
-
}
|
package/src/validate.ts
DELETED
|
@@ -1,197 +0,0 @@
|
|
|
1
|
-
import type { ConfigIssue, FeatureModule, OptionType } from "./types.ts"
|
|
2
|
-
import type { ToolPolicy } from "./tools.ts"
|
|
3
|
-
|
|
4
|
-
export type { ConfigIssue }
|
|
5
|
-
|
|
6
|
-
/** Levenshtein, capped -- only used to turn a typo into a "did you mean". */
|
|
7
|
-
function distance(a: string, b: string): number {
|
|
8
|
-
const prev = Array.from({ length: b.length + 1 }, (_, i) => i)
|
|
9
|
-
const cur = new Array<number>(b.length + 1)
|
|
10
|
-
for (let i = 1; i <= a.length; i++) {
|
|
11
|
-
cur[0] = i
|
|
12
|
-
for (let j = 1; j <= b.length; j++) {
|
|
13
|
-
cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1))
|
|
14
|
-
}
|
|
15
|
-
prev.splice(0, prev.length, ...cur)
|
|
16
|
-
}
|
|
17
|
-
return prev[b.length]
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/** Closest known name within edit distance 2, else undefined. */
|
|
21
|
-
function nearest(input: string, known: readonly string[]): string | undefined {
|
|
22
|
-
let best: string | undefined
|
|
23
|
-
let bestD = 3
|
|
24
|
-
for (const k of known) {
|
|
25
|
-
const d = distance(input.toLowerCase(), k.toLowerCase())
|
|
26
|
-
if (d < bestD) {
|
|
27
|
-
bestD = d
|
|
28
|
-
best = k
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
return best
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function unknownKey(input: string, known: readonly string[], what: string): string {
|
|
35
|
-
const guess = nearest(input, known)
|
|
36
|
-
if (guess) return `unknown ${what} "${input}" -- did you mean "${guess}"?`
|
|
37
|
-
return `unknown ${what} "${input}". Known: ${known.join(", ")}`
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function typeOf(v: unknown): OptionType | "null" {
|
|
41
|
-
if (v === null) return "null"
|
|
42
|
-
if (Array.isArray(v)) return "array"
|
|
43
|
-
const t = typeof v
|
|
44
|
-
if (t === "boolean" || t === "number" || t === "string" || t === "object") return t
|
|
45
|
-
return "object"
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
|
49
|
-
return typeof v === "object" && v !== null && !Array.isArray(v)
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Check the `toolNames` remap. A rename that silently does nothing is the worst outcome
|
|
54
|
-
* here: the proxy keeps rejecting the tool and the config looks correct. So an unknown
|
|
55
|
-
* source name is an issue, and two sources aiming at one target is an issue -- the merge
|
|
56
|
-
* would keep only the last.
|
|
57
|
-
*/
|
|
58
|
-
function validateToolNames(toolNames: unknown, features: readonly FeatureModule[]): ConfigIssue[] {
|
|
59
|
-
if (toolNames === undefined) return []
|
|
60
|
-
if (!isPlainObject(toolNames)) {
|
|
61
|
-
return [{ path: "toolNames", message: `"toolNames" must be an object, got ${typeOf(toolNames)}` }]
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
const issues: ConfigIssue[] = []
|
|
65
|
-
const declared = features.flatMap((f) => f.tools ?? [])
|
|
66
|
-
const targets = new Map<string, string>()
|
|
67
|
-
|
|
68
|
-
for (const [from, to] of Object.entries(toolNames)) {
|
|
69
|
-
if (!declared.includes(from)) {
|
|
70
|
-
issues.push({ path: `toolNames.${from}`, message: unknownKey(from, declared, "tool") })
|
|
71
|
-
continue
|
|
72
|
-
}
|
|
73
|
-
if (typeof to !== "string" || to.trim() === "") {
|
|
74
|
-
issues.push({
|
|
75
|
-
path: `toolNames.${from}`,
|
|
76
|
-
message: `must be a non-empty string, got ${typeOf(to)}`,
|
|
77
|
-
})
|
|
78
|
-
continue
|
|
79
|
-
}
|
|
80
|
-
const prior = targets.get(to)
|
|
81
|
-
if (prior) {
|
|
82
|
-
issues.push({
|
|
83
|
-
path: `toolNames.${from}`,
|
|
84
|
-
message: `"${to}" is already the target of "${prior}" -- only one would survive the merge`,
|
|
85
|
-
})
|
|
86
|
-
continue
|
|
87
|
-
}
|
|
88
|
-
targets.set(to, from)
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
return issues
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* Check overclock.json against the feature registry.
|
|
96
|
-
*
|
|
97
|
-
* Exists because an unrecognised key is otherwise a silent no-op: `killOnExist: true`
|
|
98
|
-
* reads as "option not set", the feature runs with defaults, and nothing complains.
|
|
99
|
-
* Returns every issue found -- callers warn, never throw. A bad config degrades to
|
|
100
|
-
* defaults rather than taking the plugin down.
|
|
101
|
-
*/
|
|
102
|
-
export function validateConfig(config: unknown, features: readonly FeatureModule[]): ConfigIssue[] {
|
|
103
|
-
const issues: ConfigIssue[] = []
|
|
104
|
-
if (!isPlainObject(config)) {
|
|
105
|
-
return [{ path: "", message: `config must be an object, got ${typeOf(config)}` }]
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
const TOP = ["features", "toolNames", "toolAllowlist"]
|
|
109
|
-
for (const key of Object.keys(config)) {
|
|
110
|
-
if (!TOP.includes(key)) issues.push({ path: key, message: unknownKey(key, TOP, "top-level key") })
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
issues.push(...validateToolNames(config.toolNames, features))
|
|
114
|
-
|
|
115
|
-
const { features: featuresCfg } = config
|
|
116
|
-
if (featuresCfg === undefined) return issues
|
|
117
|
-
if (!isPlainObject(featuresCfg)) {
|
|
118
|
-
issues.push({
|
|
119
|
-
path: "features",
|
|
120
|
-
message: `"features" must be an object, got ${typeOf(featuresCfg)}`,
|
|
121
|
-
})
|
|
122
|
-
return issues
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
const names = features.map((f) => f.name)
|
|
126
|
-
for (const [name, setting] of Object.entries(featuresCfg)) {
|
|
127
|
-
const feature = features.find((f) => f.name === name)
|
|
128
|
-
if (!feature) {
|
|
129
|
-
issues.push({ path: `features.${name}`, message: unknownKey(name, names, "feature") })
|
|
130
|
-
continue
|
|
131
|
-
}
|
|
132
|
-
if (typeof setting === "boolean") continue
|
|
133
|
-
if (!isPlainObject(setting)) {
|
|
134
|
-
issues.push({
|
|
135
|
-
path: `features.${name}`,
|
|
136
|
-
message: `must be true, false, or an options object -- got ${typeOf(setting)}`,
|
|
137
|
-
})
|
|
138
|
-
continue
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
const schema = feature.options ?? {}
|
|
142
|
-
const optionNames = Object.keys(schema)
|
|
143
|
-
for (const [key, value] of Object.entries(setting)) {
|
|
144
|
-
const expected = schema[key]
|
|
145
|
-
if (!expected) {
|
|
146
|
-
issues.push({
|
|
147
|
-
path: `features.${name}.${key}`,
|
|
148
|
-
message: optionNames.length
|
|
149
|
-
? unknownKey(key, optionNames, "option")
|
|
150
|
-
: `"${name}" takes no options, got "${key}"`,
|
|
151
|
-
})
|
|
152
|
-
continue
|
|
153
|
-
}
|
|
154
|
-
const actual = typeOf(value)
|
|
155
|
-
if (actual !== expected) {
|
|
156
|
-
issues.push({
|
|
157
|
-
path: `features.${name}.${key}`,
|
|
158
|
-
message: `expected ${expected}, got ${actual}`,
|
|
159
|
-
})
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
return issues
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
/**
|
|
168
|
-
* One-line inventory of what this plugin just added to the session.
|
|
169
|
-
*
|
|
170
|
-
* Not about context cost -- the full tool surface is only ~800 tokens. It is about
|
|
171
|
-
* capability: installing overclock hands the agent background shell execution and
|
|
172
|
-
* recurring scheduling, and that should not be something a user discovers by accident.
|
|
173
|
-
*/
|
|
174
|
-
export function summarise(
|
|
175
|
-
enabled: readonly FeatureModule[],
|
|
176
|
-
skipped: readonly string[],
|
|
177
|
-
policy: ToolPolicy = { rename: {}, withheld: new Set() },
|
|
178
|
-
): string {
|
|
179
|
-
const { rename, withheld } = policy
|
|
180
|
-
const offered = enabled.flatMap((f) => (f.tools ?? []).filter((t) => !withheld.has(t)))
|
|
181
|
-
// Report the name the model is actually offered, not the declared one -- under a remap the
|
|
182
|
-
// declared name appears nowhere on the wire, so listing it would misdescribe the session.
|
|
183
|
-
const parts = enabled.map((f) => {
|
|
184
|
-
const names = (f.tools ?? []).filter((t) => !withheld.has(t)).map((t) => rename[t] ?? t)
|
|
185
|
-
return `${f.name}${names.length ? ` (${names.join(", ")})` : ""}`
|
|
186
|
-
})
|
|
187
|
-
const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`
|
|
188
|
-
let line = `${plural(enabled.length, "module")}, ${plural(offered.length, "tool")}: ${parts.join(" · ")}`
|
|
189
|
-
const applied = offered.filter((t) => rename[t] && rename[t] !== t).map((t) => `${t}->${rename[t]}`)
|
|
190
|
-
if (applied.length) line += ` | renamed: ${applied.join(", ")}`
|
|
191
|
-
// Withheld tools are the one case where the session is quietly less capable than the config
|
|
192
|
-
// implies, so they are named here rather than left to the issue log alone.
|
|
193
|
-
const held = enabled.flatMap((f) => (f.tools ?? []).filter((t) => withheld.has(t)))
|
|
194
|
-
if (held.length) line += ` | withheld: ${held.join(", ")}`
|
|
195
|
-
if (skipped.length) line += ` | skipped: ${skipped.join(", ")}`
|
|
196
|
-
return line
|
|
197
|
-
}
|