opencode-overclock 0.3.0 → 0.5.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 +252 -111
- package/package.json +6 -4
- package/skills/codebase-design/DEEPENING.md +35 -0
- package/skills/codebase-design/DESIGN-IT-TWICE.md +34 -0
- package/skills/codebase-design/SKILL.md +93 -0
- package/skills/diagnosing-bugs/SKILL.md +123 -0
- package/skills/domain-modeling/ADR-FORMAT.md +55 -0
- package/skills/domain-modeling/CONTEXT-FORMAT.md +32 -0
- package/skills/domain-modeling/SKILL.md +102 -0
- package/skills/doubt/SKILL.md +80 -0
- package/skills/grilling/SKILL.md +96 -0
- package/skills/source-discipline/SKILL.md +78 -0
- package/skills/tdd/SKILL.md +87 -0
- package/skills/to-spec/SKILL.md +69 -0
- package/skills/to-spec/SPEC-TEMPLATE.md +50 -0
- package/skills/to-tickets/SKILL.md +74 -0
- package/skills/to-tickets/TICKET-TEMPLATE.md +41 -0
- 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 +67 -0
- package/src/core/policy.ts +128 -0
- package/src/core/summary.ts +33 -0
- package/src/core/types.ts +193 -0
- package/src/features/buddy.ts +1 -2
- package/src/features/guard.ts +421 -37
- package/src/features/index.ts +18 -4
- package/src/features/recovery.ts +153 -0
- package/src/features/safety.ts +147 -0
- package/src/features/sched.ts +183 -89
- package/src/features/tasks.ts +134 -33
- package/src/features/truncator.ts +116 -0
- package/src/features/usage.ts +46 -65
- package/src/features/workflow.ts +256 -0
- package/src/index.ts +96 -67
- package/src/lib/busy.ts +1 -25
- package/src/lib/exec.ts +13 -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 +317 -0
- package/src/platform/process/tmux.ts +60 -0
- package/src/platform/session/busy.ts +33 -0
- package/src/platform/session/inject.ts +89 -0
- package/src/platform/session/notify.ts +20 -0
- package/src/platform/storage/state.ts +99 -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 +120 -0
- package/src/v2/loader.ts +150 -0
- package/src/workflow/agents/codebase-researcher.ts +27 -0
- package/src/workflow/agents/design-explorer.ts +33 -0
- package/src/workflow/agents/doubt-reviewer.ts +26 -0
- package/src/workflow/agents/engineering-coach.ts +23 -0
- package/src/workflow/agents/performance-auditor.ts +29 -0
- package/src/workflow/agents/security-auditor.ts +23 -0
- package/src/workflow/agents/spec-reviewer.ts +15 -0
- package/src/workflow/agents/standards-reviewer.ts +24 -0
- package/src/workflow/agents/test-engineer.ts +28 -0
- package/src/workflow/catalog.ts +210 -0
- package/src/workflow/templates/build.ts +47 -0
- package/src/workflow/templates/define.ts +45 -0
- package/src/workflow/templates/diagnose.ts +58 -0
- package/src/workflow/templates/plan.ts +52 -0
- package/src/workflow/templates/ship.ts +64 -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
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { BusyTracker } from "../../core/types.ts"
|
|
2
|
+
|
|
3
|
+
export type { BusyTracker } from "../../core/types.ts"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Session busy tracking via `session.status` (doc-preferred; `session.idle` deprecated).
|
|
7
|
+
* Resilient to aborts, errors, and deletions so sessions do not deadlock in busy state.
|
|
8
|
+
*/
|
|
9
|
+
export function createBusyTracker(): BusyTracker {
|
|
10
|
+
const busy = new Set<string>()
|
|
11
|
+
return {
|
|
12
|
+
onEvent(event: unknown) {
|
|
13
|
+
if (!event || typeof event !== "object") return
|
|
14
|
+
const ev = event as { type?: unknown; properties?: unknown }
|
|
15
|
+
if (typeof ev.type !== "string") return
|
|
16
|
+
|
|
17
|
+
const p = (ev.properties ?? {}) as { sessionID?: string; status?: { type?: string } }
|
|
18
|
+
if (!p.sessionID) return
|
|
19
|
+
|
|
20
|
+
if (ev.type === "session.status") {
|
|
21
|
+
p.status?.type === "idle" ? busy.delete(p.sessionID) : busy.add(p.sessionID)
|
|
22
|
+
} else if (
|
|
23
|
+
ev.type === "session.idle" ||
|
|
24
|
+
ev.type === "session.deleted" ||
|
|
25
|
+
ev.type === "session.error" ||
|
|
26
|
+
ev.type === "session.aborted"
|
|
27
|
+
) {
|
|
28
|
+
busy.delete(p.sessionID)
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
isBusy: (id) => (id ? busy.has(id) : false),
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { PluginInput } from "@opencode-ai/plugin"
|
|
2
|
+
import { toast } from "./notify.ts"
|
|
3
|
+
|
|
4
|
+
export { toast, type ToastVariant } from "./notify.ts"
|
|
5
|
+
|
|
6
|
+
type Client = PluginInput["client"]
|
|
7
|
+
export type ModelRef = { providerID: string; modelID: string }
|
|
8
|
+
export type SessionContext = {
|
|
9
|
+
model?: ModelRef
|
|
10
|
+
agent?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface InjectOptions {
|
|
14
|
+
noReply?: boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Session's active context = model of last assistant message and most recent agent name.
|
|
19
|
+
* Without model, promptAsync falls back to config default model -> injected turns
|
|
20
|
+
* run on the wrong model (and pile up QUEUED behind a hung default).
|
|
21
|
+
* Preserving agent ensures turns continue under the active persona.
|
|
22
|
+
*/
|
|
23
|
+
export async function sessionContext(client: Client, sessionID: string): Promise<SessionContext> {
|
|
24
|
+
try {
|
|
25
|
+
const res = await client.session.messages({ path: { id: sessionID } })
|
|
26
|
+
const msgs = res.data ?? []
|
|
27
|
+
let model: ModelRef | undefined
|
|
28
|
+
let agent: string | undefined
|
|
29
|
+
|
|
30
|
+
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
31
|
+
const info = msgs[i]?.info as
|
|
32
|
+
{ role?: string; modelID?: string; providerID?: string; agent?: string } | undefined
|
|
33
|
+
if (!model && info?.role === "assistant" && info.modelID && info.providerID) {
|
|
34
|
+
model = { providerID: info.providerID, modelID: info.modelID }
|
|
35
|
+
}
|
|
36
|
+
if (!agent && info?.agent) {
|
|
37
|
+
agent = info.agent
|
|
38
|
+
}
|
|
39
|
+
if (model && agent) break
|
|
40
|
+
}
|
|
41
|
+
return { model, agent }
|
|
42
|
+
} catch (e) {
|
|
43
|
+
console.warn(`[overclock] sessionContext lookup failed (${sessionID}): ${e}`)
|
|
44
|
+
return {}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Session's active model = model of last assistant message.
|
|
50
|
+
*/
|
|
51
|
+
export async function sessionModel(client: Client, sessionID: string): Promise<ModelRef | undefined> {
|
|
52
|
+
const ctx = await sessionContext(client, sessionID)
|
|
53
|
+
return ctx.model
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Re-entry: push text into session as user prompt, on the session's own model and agent.
|
|
58
|
+
* promptAsync = fire-and-forget, server queues if busy. Failure -> warn, never throw.
|
|
59
|
+
*/
|
|
60
|
+
export async function inject(
|
|
61
|
+
client: Client,
|
|
62
|
+
sessionID: string,
|
|
63
|
+
text: string,
|
|
64
|
+
options?: InjectOptions,
|
|
65
|
+
): Promise<boolean> {
|
|
66
|
+
try {
|
|
67
|
+
const ctx = await sessionContext(client, sessionID)
|
|
68
|
+
const res = await (client.session.promptAsync as any)({
|
|
69
|
+
path: { id: sessionID },
|
|
70
|
+
throwOnError: true,
|
|
71
|
+
body: {
|
|
72
|
+
parts: [{ type: "text", text }],
|
|
73
|
+
...(ctx.model ? { model: ctx.model } : {}),
|
|
74
|
+
...(ctx.agent ? { agent: ctx.agent } : {}),
|
|
75
|
+
...(options?.noReply ? { noReply: true } : {}),
|
|
76
|
+
},
|
|
77
|
+
})
|
|
78
|
+
if (res && typeof res === "object" && "error" in res && (res as any).error) {
|
|
79
|
+
console.warn(
|
|
80
|
+
`[overclock] inject failed (session ${sessionID}): ${JSON.stringify((res as any).error)}`,
|
|
81
|
+
)
|
|
82
|
+
return false
|
|
83
|
+
}
|
|
84
|
+
return true
|
|
85
|
+
} catch (e) {
|
|
86
|
+
console.warn(`[overclock] inject failed (session ${sessionID}): ${e}`)
|
|
87
|
+
return false
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { PluginInput } from "@opencode-ai/plugin"
|
|
2
|
+
|
|
3
|
+
type Client = PluginInput["client"]
|
|
4
|
+
|
|
5
|
+
export type ToastVariant = "info" | "success" | "warning" | "error"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* TUI toast notification, best-effort (swallows errors in headless/no-TUI environments).
|
|
9
|
+
*/
|
|
10
|
+
export async function toast(
|
|
11
|
+
client: Client,
|
|
12
|
+
message: string,
|
|
13
|
+
variant: ToastVariant = "info",
|
|
14
|
+
): Promise<void> {
|
|
15
|
+
try {
|
|
16
|
+
await client.tui.showToast({ body: { message, variant } })
|
|
17
|
+
} catch {
|
|
18
|
+
// no TUI attached or client lacks tui surface
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { mkdir, copyFile } from "node:fs/promises"
|
|
2
|
+
import { writeFileSync, renameSync, unlinkSync, mkdirSync } from "node:fs"
|
|
3
|
+
import { dirname } from "node:path"
|
|
4
|
+
import { shellQuote } from "../process/exec.ts"
|
|
5
|
+
|
|
6
|
+
export { shellQuote }
|
|
7
|
+
|
|
8
|
+
/** State root: <project>/.opencode/overclock/[sub]. Pure -- creates nothing. */
|
|
9
|
+
export function stateDir(directory: string, sub?: string): string {
|
|
10
|
+
return `${directory}/.opencode/overclock${sub ? `/${sub}` : ""}`
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** State root: <project>/.opencode/overclock/[sub]. Creates if missing. */
|
|
14
|
+
export async function ensureStateDir(directory: string, sub?: string): Promise<string> {
|
|
15
|
+
const dir = stateDir(directory, sub)
|
|
16
|
+
await mkdir(dir, { recursive: true })
|
|
17
|
+
return dir
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* True once per project, then never again. Marker lives beside the other state, so
|
|
22
|
+
* deleting .opencode/overclock/ re-arms the first-run notice.
|
|
23
|
+
*/
|
|
24
|
+
export async function firstRun(directory: string): Promise<boolean> {
|
|
25
|
+
const dir = await ensureStateDir(directory)
|
|
26
|
+
const marker = Bun.file(`${dir}/.installed`)
|
|
27
|
+
if (await marker.exists()) return false
|
|
28
|
+
await Bun.write(marker, new Date().toISOString())
|
|
29
|
+
return true
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function readJson<T>(path: string, fallback: T): Promise<T> {
|
|
33
|
+
const file = Bun.file(path)
|
|
34
|
+
if (!(await file.exists())) return fallback
|
|
35
|
+
try {
|
|
36
|
+
return (await file.json()) as T
|
|
37
|
+
} catch (e) {
|
|
38
|
+
console.warn(`[overclock] failed to parse JSON at ${path}: ${e}`)
|
|
39
|
+
try {
|
|
40
|
+
await copyFile(path, `${path}.corrupt.${Date.now()}`)
|
|
41
|
+
} catch {}
|
|
42
|
+
return fallback
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Serializes the value formatted with 2 spaces and atomically writes to the destination path.
|
|
48
|
+
*/
|
|
49
|
+
export async function writeJson(path: string, value: unknown): Promise<void> {
|
|
50
|
+
const tmpPath = `${path}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}.tmp`
|
|
51
|
+
const content = JSON.stringify(value, null, 2)
|
|
52
|
+
try {
|
|
53
|
+
writeFileSync(tmpPath, content)
|
|
54
|
+
renameSync(tmpPath, path)
|
|
55
|
+
} catch (err: any) {
|
|
56
|
+
if (err?.code === "ENOENT") {
|
|
57
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
58
|
+
writeFileSync(tmpPath, content)
|
|
59
|
+
renameSync(tmpPath, path)
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
unlinkSync(tmpPath)
|
|
64
|
+
} catch {}
|
|
65
|
+
throw err
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** A typed state file accessor. */
|
|
70
|
+
export interface Store<T> {
|
|
71
|
+
readonly file: string
|
|
72
|
+
path(directory: string): string
|
|
73
|
+
read(directory: string): Promise<T>
|
|
74
|
+
readMaybe(directory: string): Promise<T | undefined>
|
|
75
|
+
write(directory: string, value: T): Promise<void>
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Factory for typed state file stores. */
|
|
79
|
+
export function defineStore<T>(file: string, fallback: () => T): Store<T> {
|
|
80
|
+
const path = (directory: string) => `${stateDir(directory)}/${file}`
|
|
81
|
+
return {
|
|
82
|
+
file,
|
|
83
|
+
path,
|
|
84
|
+
read: (directory) => readJson<T>(path(directory), fallback()),
|
|
85
|
+
readMaybe: async (directory) => {
|
|
86
|
+
const target = path(directory)
|
|
87
|
+
try {
|
|
88
|
+
if (!(await Bun.file(target).exists())) return undefined
|
|
89
|
+
} catch {
|
|
90
|
+
return undefined
|
|
91
|
+
}
|
|
92
|
+
return readJson<T>(target, fallback())
|
|
93
|
+
},
|
|
94
|
+
write: async (directory, value) => {
|
|
95
|
+
await ensureStateDir(directory)
|
|
96
|
+
await writeJson(path(directory), value)
|
|
97
|
+
},
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { defineStore } from "./state.ts"
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------- tasks
|
|
4
|
+
|
|
5
|
+
export interface TaskMirrorEntry {
|
|
6
|
+
id: string
|
|
7
|
+
description: string
|
|
8
|
+
status: "running" | "exited" | "killed"
|
|
9
|
+
exitCode: number | null
|
|
10
|
+
startedAt: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const taskStore = defineStore<TaskMirrorEntry[]>("tasks.json", () => [])
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------- usage
|
|
16
|
+
|
|
17
|
+
export interface UsageTokens {
|
|
18
|
+
input: number
|
|
19
|
+
output: number
|
|
20
|
+
reasoning: number
|
|
21
|
+
cacheRead: number
|
|
22
|
+
cacheWrite: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface DayBucket {
|
|
26
|
+
cost: number
|
|
27
|
+
tokens: UsageTokens
|
|
28
|
+
messages: number
|
|
29
|
+
/** message ids already counted, so a replayed event cannot double-bill */
|
|
30
|
+
seen: string[]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface UsageState {
|
|
34
|
+
days: Record<string, DayBucket>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** What the TUI needs off a day bucket. `seen` is write-side bookkeeping. */
|
|
38
|
+
export type DayBucketView = Omit<DayBucket, "seen">
|
|
39
|
+
|
|
40
|
+
export interface UsageStateView {
|
|
41
|
+
days?: Record<string, DayBucketView | undefined>
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const usageStore = defineStore<UsageState>("usage.json", () => ({ days: {} }))
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------- schedules
|
|
47
|
+
|
|
48
|
+
export interface ScheduleEntry {
|
|
49
|
+
id: string
|
|
50
|
+
spec: string
|
|
51
|
+
prompt: string
|
|
52
|
+
target: "current" | "new-session"
|
|
53
|
+
/** creator; also the inject target when target=current */
|
|
54
|
+
sessionID: string
|
|
55
|
+
createdAt: string
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The TUI lists schedules; it has no business reading the prompt or the session id. */
|
|
59
|
+
export type ScheduleEntryView = Pick<ScheduleEntry, "id" | "spec">
|
|
60
|
+
|
|
61
|
+
export const scheduleStore = defineStore<ScheduleEntry[]>("schedules.json", () => [])
|
package/src/summary.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { summarise } from "./core/summary.ts"
|
package/src/tools.ts
CHANGED
|
@@ -1,244 +1,8 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
* list only makes the warning less complete, never wrong.
|
|
10
|
-
*/
|
|
11
|
-
export const HOST_TOOL_IDS: readonly string[] = [
|
|
12
|
-
"apply_patch",
|
|
13
|
-
"bash",
|
|
14
|
-
"edit",
|
|
15
|
-
"glob",
|
|
16
|
-
"grep",
|
|
17
|
-
"invalid",
|
|
18
|
-
"question",
|
|
19
|
-
"read",
|
|
20
|
-
"skill",
|
|
21
|
-
"task",
|
|
22
|
-
"todowrite",
|
|
23
|
-
"webfetch",
|
|
24
|
-
"websearch",
|
|
25
|
-
"write",
|
|
26
|
-
]
|
|
27
|
-
|
|
28
|
-
export interface KnownAllowlist {
|
|
29
|
-
/** every name the list permits */
|
|
30
|
-
names: readonly string[]
|
|
31
|
-
/**
|
|
32
|
-
* declared tool name -> the name from this list it is offered under. Only for names that
|
|
33
|
-
* mean the same operation; nothing is invented, so this table stays short.
|
|
34
|
-
*/
|
|
35
|
-
aliases: Readonly<Record<string, string>>
|
|
36
|
-
/**
|
|
37
|
-
* Tools with no honest alias in this list, and why. Recorded rather than left blank so the
|
|
38
|
-
* absence is a decision someone made, not an oversight -- a test requires every tool to be
|
|
39
|
-
* in `aliases` or here, and the reason is shown when such a tool gets withheld.
|
|
40
|
-
*/
|
|
41
|
-
unaliased: Readonly<Record<string, string>>
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Named allowlists, usable anywhere a tool name is accepted in `toolAllowlist`.
|
|
46
|
-
*
|
|
47
|
-
* `claude-code` is Claude Code's registered tool set. opencode's own ids are snake_case and
|
|
48
|
-
* disjoint from it, so every name here is free for this plugin to use.
|
|
49
|
-
*
|
|
50
|
-
* `aliases` only covers tools where a name in the list denotes the same operation, so nothing
|
|
51
|
-
* here is a guess: background tasks and cron already exist in this vocabulary. Anything else is
|
|
52
|
-
* listed in `unaliased` with the reason, because picking an unrelated name on a user's behalf
|
|
53
|
-
* would mislead the model about what the tool does. Map those yourself with `toolNames`.
|
|
54
|
-
*
|
|
55
|
-
* Adding a tool: give it an alias or an `unaliased` reason in the same commit. A test fails
|
|
56
|
-
* otherwise, so the decision surfaces in CI rather than as a rejected request mid-session.
|
|
57
|
-
*/
|
|
58
|
-
export const KNOWN_ALLOWLISTS: Readonly<Record<string, KnownAllowlist>> = {
|
|
59
|
-
"claude-code": {
|
|
60
|
-
names: [
|
|
61
|
-
"Read",
|
|
62
|
-
"Write",
|
|
63
|
-
"Edit",
|
|
64
|
-
"MultiEdit",
|
|
65
|
-
"NotebookEdit",
|
|
66
|
-
"Glob",
|
|
67
|
-
"Grep",
|
|
68
|
-
"Bash",
|
|
69
|
-
"Agent",
|
|
70
|
-
"Task",
|
|
71
|
-
"Workflow",
|
|
72
|
-
"TodoWrite",
|
|
73
|
-
"TaskCreate",
|
|
74
|
-
"TaskGet",
|
|
75
|
-
"TaskList",
|
|
76
|
-
"TaskUpdate",
|
|
77
|
-
"TaskStop",
|
|
78
|
-
"TaskOutput",
|
|
79
|
-
"TeamCreate",
|
|
80
|
-
"TeamDelete",
|
|
81
|
-
"SendMessage",
|
|
82
|
-
"EnterPlanMode",
|
|
83
|
-
"ExitPlanMode",
|
|
84
|
-
"EnterWorktree",
|
|
85
|
-
"ExitWorktree",
|
|
86
|
-
"ListMcpResourcesTool",
|
|
87
|
-
"WaitForMcpServers",
|
|
88
|
-
"ToolSearch",
|
|
89
|
-
"Skill",
|
|
90
|
-
"CronCreate",
|
|
91
|
-
"CronDelete",
|
|
92
|
-
"CronList",
|
|
93
|
-
"ScheduleWakeup",
|
|
94
|
-
"AskUserQuestion",
|
|
95
|
-
"StructuredOutput",
|
|
96
|
-
"ValidationResult",
|
|
97
|
-
"ReportFindings",
|
|
98
|
-
"LSP",
|
|
99
|
-
],
|
|
100
|
-
aliases: {
|
|
101
|
-
task_run: "TaskCreate",
|
|
102
|
-
task_status: "TaskList",
|
|
103
|
-
task_output: "TaskOutput",
|
|
104
|
-
task_kill: "TaskStop",
|
|
105
|
-
schedule_create: "CronCreate",
|
|
106
|
-
schedule_list: "CronList",
|
|
107
|
-
schedule_delete: "CronDelete",
|
|
108
|
-
},
|
|
109
|
-
unaliased: {
|
|
110
|
-
bash_unsandboxed: "the only fitting name is `Bash`, which is also an opencode built-in",
|
|
111
|
-
checkpoint_list: "no name in this list denotes session checkpoints",
|
|
112
|
-
checkpoint_revert: "no name in this list denotes session checkpoints",
|
|
113
|
-
checkpoint_restore: "no name in this list denotes session checkpoints",
|
|
114
|
-
usage_report: "no name in this list denotes cost/token telemetry",
|
|
115
|
-
},
|
|
116
|
-
},
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
export interface ToolPolicy {
|
|
120
|
-
/** declared name -> model-visible name */
|
|
121
|
-
rename: Record<string, string>
|
|
122
|
-
/** declared names withheld from the model entirely (no allowed name to use) */
|
|
123
|
-
withheld: Set<string>
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
export const EMPTY_POLICY: ToolPolicy = { rename: {}, withheld: new Set() }
|
|
127
|
-
|
|
128
|
-
/** Entries like "claude-code" name a bundled list; a literal tool id would not look like this. */
|
|
129
|
-
function looksLikeListName(entry: string): boolean {
|
|
130
|
-
return /^[a-z0-9]+(-[a-z0-9]+)+$/.test(entry)
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/**
|
|
134
|
-
* Expand `toolAllowlist` into the names it permits plus the aliases any named list brings.
|
|
135
|
-
* Entries are either a known list name or a literal tool name, so extending a bundled list is
|
|
136
|
-
* `["claude-code", "MyExtraTool"]` -- no separate key, no re-listing what the bundle covers.
|
|
137
|
-
*/
|
|
138
|
-
export function resolveAllowlist(value: unknown): {
|
|
139
|
-
names?: string[]
|
|
140
|
-
aliases: Record<string, string>
|
|
141
|
-
/** declared tool -> why no bundled list offered a name for it */
|
|
142
|
-
unaliased: Record<string, string>
|
|
143
|
-
issues: ConfigIssue[]
|
|
144
|
-
} {
|
|
145
|
-
const aliases: Record<string, string> = {}
|
|
146
|
-
const unaliased: Record<string, string> = {}
|
|
147
|
-
if (value === undefined) return { aliases, unaliased, issues: [] }
|
|
148
|
-
|
|
149
|
-
const entries = typeof value === "string" ? [value] : value
|
|
150
|
-
if (!Array.isArray(entries) || !entries.every((v) => typeof v === "string")) {
|
|
151
|
-
return {
|
|
152
|
-
aliases,
|
|
153
|
-
unaliased,
|
|
154
|
-
issues: [
|
|
155
|
-
{
|
|
156
|
-
path: "toolAllowlist",
|
|
157
|
-
message: `must be a name or an array of names (a known list is ${Object.keys(KNOWN_ALLOWLISTS).join(", ")}), got ${Array.isArray(value) ? "array with non-strings" : typeof value}`,
|
|
158
|
-
},
|
|
159
|
-
],
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
const names: string[] = []
|
|
164
|
-
const issues: ConfigIssue[] = []
|
|
165
|
-
for (const entry of entries as string[]) {
|
|
166
|
-
const known = KNOWN_ALLOWLISTS[entry]
|
|
167
|
-
if (known) {
|
|
168
|
-
names.push(...known.names)
|
|
169
|
-
Object.assign(aliases, known.aliases)
|
|
170
|
-
Object.assign(unaliased, known.unaliased)
|
|
171
|
-
continue
|
|
172
|
-
}
|
|
173
|
-
// A typo'd list name would otherwise pass as a literal tool name, withhold everything, and
|
|
174
|
-
// suggest the typo itself as a free name. Cheap to catch, confusing to debug.
|
|
175
|
-
if (looksLikeListName(entry)) {
|
|
176
|
-
issues.push({
|
|
177
|
-
path: "toolAllowlist",
|
|
178
|
-
message: `"${entry}" looks like a known list but is not one (known: ${Object.keys(KNOWN_ALLOWLISTS).join(", ")}) -- treating it as a literal tool name`,
|
|
179
|
-
})
|
|
180
|
-
}
|
|
181
|
-
names.push(entry)
|
|
182
|
-
}
|
|
183
|
-
return { names, aliases, unaliased, issues }
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
/**
|
|
187
|
-
* Fold the allowlist's aliases + explicit renames into one policy, and report what a human
|
|
188
|
-
* needs to act on. Every check here exists because the failure it catches is otherwise
|
|
189
|
-
* invisible until a request comes back rejected:
|
|
190
|
-
* - a tool with no permitted name is withheld, and the still-free names are listed so picking
|
|
191
|
-
* one is a single config line;
|
|
192
|
-
* - a name that collides with a host built-in (or its case-twin) is called out, since that
|
|
193
|
-
* replaces the built-in or reads as a duplicate.
|
|
194
|
-
*/
|
|
195
|
-
export function resolveToolPolicy(
|
|
196
|
-
config: Pick<OverclockConfig, "toolNames" | "toolAllowlist">,
|
|
197
|
-
features: readonly FeatureModule[],
|
|
198
|
-
): { policy: ToolPolicy; issues: ConfigIssue[] } {
|
|
199
|
-
const { names: allowlist, aliases, unaliased, issues } = resolveAllowlist(config.toolAllowlist)
|
|
200
|
-
|
|
201
|
-
// Explicit names win: a bundled list is a starting point, not a straitjacket.
|
|
202
|
-
const rename: Record<string, string> = { ...aliases, ...(config.toolNames ?? {}) }
|
|
203
|
-
|
|
204
|
-
const final = new Map<string, string>() // declared -> model-visible
|
|
205
|
-
for (const name of features.flatMap((f) => f.tools ?? [])) {
|
|
206
|
-
final.set(name, rename[name] ?? name)
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
for (const [name, visible] of final) {
|
|
210
|
-
const twin = HOST_TOOL_IDS.find((id) => id.toLowerCase() === visible.toLowerCase())
|
|
211
|
-
if (!twin) continue
|
|
212
|
-
issues.push({
|
|
213
|
-
path: `tool "${name}"`,
|
|
214
|
-
message:
|
|
215
|
-
twin === visible
|
|
216
|
-
? `"${visible}" is an opencode built-in -- registering it replaces that built-in`
|
|
217
|
-
: `"${visible}" differs from opencode's built-in "${twin}" only by case; anything matching case-insensitively sees one name twice`,
|
|
218
|
-
})
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
const withheld = new Set<string>()
|
|
222
|
-
if (allowlist) {
|
|
223
|
-
const allowed = new Set(allowlist)
|
|
224
|
-
const taken = new Set([...final.values()].filter((v) => allowed.has(v)))
|
|
225
|
-
const builtin = new Set(HOST_TOOL_IDS.map((id) => id.toLowerCase()))
|
|
226
|
-
// Suggesting a name that would immediately earn a built-in collision warning is worse than
|
|
227
|
-
// suggesting nothing, so case-twins of opencode's own ids are not offered.
|
|
228
|
-
const free = allowlist.filter((n) => !taken.has(n) && !builtin.has(n.toLowerCase()))
|
|
229
|
-
for (const [name, visible] of final) {
|
|
230
|
-
if (allowed.has(visible)) continue
|
|
231
|
-
withheld.add(name)
|
|
232
|
-
const why = unaliased[name] ? ` (${unaliased[name]})` : ""
|
|
233
|
-
issues.push({
|
|
234
|
-
path: `tool "${name}"`,
|
|
235
|
-
message:
|
|
236
|
-
`"${visible}" is not in toolAllowlist${why} -- withheld from the model. ` +
|
|
237
|
-
`Pick a name for it via toolNames (free: ${free.slice(0, 4).join(", ") || "none left"}), ` +
|
|
238
|
-
`or add one to toolAllowlist`,
|
|
239
|
-
})
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
return { policy: { rename, withheld }, issues }
|
|
244
|
-
}
|
|
1
|
+
export {
|
|
2
|
+
applyToolPolicy,
|
|
3
|
+
EMPTY_POLICY,
|
|
4
|
+
HOST_TOOL_IDS,
|
|
5
|
+
renameInText,
|
|
6
|
+
resolveToolPolicy,
|
|
7
|
+
type ToolPolicy,
|
|
8
|
+
} from "./core/policy.ts"
|