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
|
@@ -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"
|
package/src/tui.ts
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui"
|
|
2
2
|
import { registerBuddy } from "./buddy/tui.ts"
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
import { createUi } from "./lib/ui.ts"
|
|
4
|
+
import {
|
|
5
|
+
scheduleStore,
|
|
6
|
+
taskStore,
|
|
7
|
+
usageStore,
|
|
8
|
+
type ScheduleEntryView,
|
|
9
|
+
type TaskMirrorEntry,
|
|
10
|
+
type UsageStateView,
|
|
11
|
+
} from "./lib/mirror.ts"
|
|
5
12
|
|
|
6
13
|
export interface TuiOptions {
|
|
7
14
|
notifyIdle?: boolean
|
|
@@ -11,44 +18,16 @@ export interface TuiOptions {
|
|
|
11
18
|
buddy?: boolean
|
|
12
19
|
}
|
|
13
20
|
|
|
14
|
-
/**
|
|
15
|
-
|
|
16
|
-
* config file governs both surfaces. `features.buddy: false` disables; missing
|
|
17
|
-
* file or unreadable config = default on.
|
|
18
|
-
*/
|
|
19
|
-
export async function buddyEnabledInConfig(directory: string): Promise<boolean> {
|
|
20
|
-
try {
|
|
21
|
-
const file = Bun.file(`${directory}/.opencode/overclock.json`)
|
|
22
|
-
if (!(await file.exists())) return true
|
|
23
|
-
const config = (await file.json()) as { features?: Record<string, unknown> }
|
|
24
|
-
return config.features?.["buddy"] !== false
|
|
25
|
-
} catch {
|
|
26
|
-
return true
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export interface TaskMirrorEntry {
|
|
31
|
-
id: string
|
|
32
|
-
description: string
|
|
33
|
-
status: "running" | "exited" | "killed"
|
|
34
|
-
exitCode: number | null
|
|
35
|
-
startedAt: number
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export interface UsageDayBucket {
|
|
39
|
-
cost: number
|
|
40
|
-
tokens: { input: number; output: number; reasoning: number; cacheRead: number; cacheWrite: number }
|
|
41
|
-
messages: number
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export interface UsageStateShape {
|
|
45
|
-
days: Record<string, UsageDayBucket>
|
|
46
|
-
}
|
|
21
|
+
/** Read views the summary formatters accept. Re-exported for tests and downstream typing. */
|
|
22
|
+
export type { TaskMirrorEntry, ScheduleEntryView, UsageStateView }
|
|
47
23
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
24
|
+
/** Check if buddy feature is enabled via plugin options. */
|
|
25
|
+
export function isBuddyEnabled(options?: TuiOptions): boolean {
|
|
26
|
+
if (!options) return true
|
|
27
|
+
if (options.buddy === false) return false
|
|
28
|
+
const features = (options as { features?: Record<string, unknown> }).features
|
|
29
|
+
if (features?.buddy === false) return false
|
|
30
|
+
return true
|
|
52
31
|
}
|
|
53
32
|
|
|
54
33
|
/** Local YYYY-MM-DD from an epoch-ms timestamp (mirrors usage.ts, kept local to stay decoupled). */
|
|
@@ -75,7 +54,7 @@ export function formatTasksSummary(entries: TaskMirrorEntry[] | undefined | null
|
|
|
75
54
|
|
|
76
55
|
/** Pure: format the /oc-usage toast summary from a parsed usage.json, for a given "now". */
|
|
77
56
|
export function formatUsageSummary(
|
|
78
|
-
state:
|
|
57
|
+
state: UsageStateView | undefined | null,
|
|
79
58
|
now: number = Date.now(),
|
|
80
59
|
): string {
|
|
81
60
|
if (!state) return "no data yet"
|
|
@@ -86,169 +65,61 @@ export function formatUsageSummary(
|
|
|
86
65
|
}
|
|
87
66
|
|
|
88
67
|
/** Pure: format the /oc-schedules toast summary from a parsed schedules.json. */
|
|
89
|
-
export function formatSchedulesSummary(schedules:
|
|
68
|
+
export function formatSchedulesSummary(schedules: ScheduleEntryView[] | undefined | null): string {
|
|
90
69
|
if (!schedules) return "no data yet"
|
|
91
70
|
if (!schedules.length) return "no schedules"
|
|
92
71
|
const list = schedules.map((s) => `${s.id} (${s.spec})`).join(", ")
|
|
93
72
|
return `${schedules.length} schedule${schedules.length === 1 ? "" : "s"}: ${list}`
|
|
94
73
|
}
|
|
95
74
|
|
|
96
|
-
async function readState<T>(directory: string, file: string): Promise<T | undefined> {
|
|
97
|
-
try {
|
|
98
|
-
const f = Bun.file(`${directory}/${STATE_SUBDIR}/${file}`)
|
|
99
|
-
if (!(await f.exists())) return undefined
|
|
100
|
-
return (await f.json()) as T
|
|
101
|
-
} catch {
|
|
102
|
-
return undefined
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
75
|
const tui: TuiPlugin = async (api, options) => {
|
|
107
76
|
const opts = (options ?? {}) as TuiOptions
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
if (notifyIdle) {
|
|
117
|
-
unsubs.push(
|
|
118
|
-
api.event.on("session.status", (event) => {
|
|
119
|
-
if (event.properties.status.type !== "idle") return
|
|
120
|
-
void api.attention.notify({
|
|
121
|
-
title: "opencode",
|
|
122
|
-
message: "turn complete",
|
|
123
|
-
sound: { name: "done", when: "blurred" },
|
|
124
|
-
notification: { when: "blurred" },
|
|
125
|
-
})
|
|
126
|
-
}),
|
|
127
|
-
)
|
|
128
|
-
}
|
|
129
|
-
} catch (e) {
|
|
130
|
-
console.warn(`[overclock-tui] session.status subscription failed: ${e}`)
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
try {
|
|
134
|
-
if (notifyPermission) {
|
|
135
|
-
unsubs.push(
|
|
136
|
-
api.event.on("permission.asked", () => {
|
|
137
|
-
void api.attention.notify({
|
|
138
|
-
title: "opencode",
|
|
139
|
-
message: "needs permission",
|
|
140
|
-
sound: { name: "permission", when: "blurred" },
|
|
141
|
-
notification: { when: "blurred" },
|
|
142
|
-
})
|
|
143
|
-
}),
|
|
144
|
-
)
|
|
145
|
-
}
|
|
146
|
-
} catch (e) {
|
|
147
|
-
console.warn(`[overclock-tui] permission.asked subscription failed: ${e}`)
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
try {
|
|
151
|
-
if (notifyQuestion) {
|
|
152
|
-
unsubs.push(
|
|
153
|
-
api.event.on("question.asked", () => {
|
|
154
|
-
void api.attention.notify({
|
|
155
|
-
title: "opencode",
|
|
156
|
-
message: "asking a question",
|
|
157
|
-
sound: { name: "question", when: "blurred" },
|
|
158
|
-
notification: { when: "blurred" },
|
|
159
|
-
})
|
|
160
|
-
}),
|
|
161
|
-
)
|
|
162
|
-
}
|
|
163
|
-
} catch (e) {
|
|
164
|
-
console.warn(`[overclock-tui] question.asked subscription failed: ${e}`)
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
try {
|
|
168
|
-
if (notifyError) {
|
|
169
|
-
unsubs.push(
|
|
170
|
-
api.event.on("session.error", () => {
|
|
171
|
-
void api.attention.notify({
|
|
172
|
-
title: "opencode",
|
|
173
|
-
message: "session error",
|
|
174
|
-
sound: { name: "error", when: "blurred" },
|
|
175
|
-
notification: { when: "blurred" },
|
|
176
|
-
})
|
|
177
|
-
}),
|
|
178
|
-
)
|
|
179
|
-
}
|
|
180
|
-
} catch (e) {
|
|
181
|
-
console.warn(`[overclock-tui] session.error subscription failed: ${e}`)
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
try {
|
|
185
|
-
for (const unsub of unsubs) api.lifecycle.onDispose(async () => unsub())
|
|
186
|
-
} catch (e) {
|
|
187
|
-
console.warn(`[overclock-tui] lifecycle registration failed: ${e}`)
|
|
77
|
+
const ui = createUi(api)
|
|
78
|
+
|
|
79
|
+
if (opts.notifyIdle !== false) {
|
|
80
|
+
ui.on("session.status", (event) => {
|
|
81
|
+
if (event.properties?.status?.type === "idle") {
|
|
82
|
+
ui.notify({ message: "turn complete", sound: "done" })
|
|
83
|
+
}
|
|
84
|
+
})
|
|
188
85
|
}
|
|
189
86
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
{
|
|
193
|
-
title: "Overclock: Tasks",
|
|
194
|
-
value: "overclock.tasks",
|
|
195
|
-
slash: { name: "oc-tasks" },
|
|
196
|
-
onSelect: async () => {
|
|
197
|
-
const entries = await readState<TaskMirrorEntry[]>(api.state.path.directory, "tasks.json")
|
|
198
|
-
api.ui.toast({ message: formatTasksSummary(entries) })
|
|
199
|
-
},
|
|
200
|
-
},
|
|
201
|
-
])
|
|
202
|
-
if (unregister) api.lifecycle.onDispose(async () => unregister())
|
|
203
|
-
} catch (e) {
|
|
204
|
-
console.warn(`[overclock-tui] /oc-tasks command registration failed: ${e}`)
|
|
87
|
+
if (opts.notifyPermission !== false) {
|
|
88
|
+
ui.on("permission.asked", () => ui.notify({ message: "needs permission", sound: "permission" }))
|
|
205
89
|
}
|
|
206
90
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
{
|
|
210
|
-
title: "Overclock: Usage",
|
|
211
|
-
value: "overclock.usage",
|
|
212
|
-
slash: { name: "oc-usage" },
|
|
213
|
-
onSelect: async () => {
|
|
214
|
-
const state = await readState<UsageStateShape>(api.state.path.directory, "usage.json")
|
|
215
|
-
api.ui.toast({ message: formatUsageSummary(state) })
|
|
216
|
-
},
|
|
217
|
-
},
|
|
218
|
-
])
|
|
219
|
-
if (unregister) api.lifecycle.onDispose(async () => unregister())
|
|
220
|
-
} catch (e) {
|
|
221
|
-
console.warn(`[overclock-tui] /oc-usage command registration failed: ${e}`)
|
|
91
|
+
if (opts.notifyQuestion !== false) {
|
|
92
|
+
ui.on("question.asked", () => ui.notify({ message: "asking a question", sound: "question" }))
|
|
222
93
|
}
|
|
223
94
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
{
|
|
227
|
-
title: "Overclock: Schedules",
|
|
228
|
-
value: "overclock.schedules",
|
|
229
|
-
slash: { name: "oc-schedules" },
|
|
230
|
-
onSelect: async () => {
|
|
231
|
-
const schedules = await readState<ScheduleMirrorEntry[]>(
|
|
232
|
-
api.state.path.directory,
|
|
233
|
-
"schedules.json",
|
|
234
|
-
)
|
|
235
|
-
api.ui.toast({ message: formatSchedulesSummary(schedules) })
|
|
236
|
-
},
|
|
237
|
-
},
|
|
238
|
-
])
|
|
239
|
-
if (unregister) api.lifecycle.onDispose(async () => unregister())
|
|
240
|
-
} catch (e) {
|
|
241
|
-
console.warn(`[overclock-tui] /oc-schedules command registration failed: ${e}`)
|
|
95
|
+
if (opts.notifyError !== false) {
|
|
96
|
+
ui.on("session.error", () => ui.notify({ message: "session error", sound: "error" }))
|
|
242
97
|
}
|
|
243
98
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
99
|
+
ui.command({
|
|
100
|
+
title: "Overclock: Tasks",
|
|
101
|
+
slash: "oc-tasks",
|
|
102
|
+
run: async () => ui.toast(formatTasksSummary(await ui.readOptional(taskStore))),
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
ui.command({
|
|
106
|
+
title: "Overclock: Usage",
|
|
107
|
+
slash: "oc-usage",
|
|
108
|
+
run: async () => ui.toast(formatUsageSummary(await ui.readOptional(usageStore))),
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
ui.command({
|
|
112
|
+
title: "Overclock: Schedules",
|
|
113
|
+
slash: "oc-schedules",
|
|
114
|
+
run: async () => ui.toast(formatSchedulesSummary(await ui.readOptional(scheduleStore))),
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
if (isBuddyEnabled(opts)) {
|
|
118
|
+
try {
|
|
119
|
+
await registerBuddy(ui)
|
|
120
|
+
} catch (e) {
|
|
121
|
+
console.warn(`[overclock-tui] buddy disabled: ${e}`)
|
|
249
122
|
}
|
|
250
|
-
} catch (e) {
|
|
251
|
-
console.warn(`[overclock-tui] buddy disabled: ${e}`)
|
|
252
123
|
}
|
|
253
124
|
}
|
|
254
125
|
|
package/src/types.ts
CHANGED
|
@@ -1,73 +1 @@
|
|
|
1
|
-
|
|
2
|
-
import type { BusyTracker } from "./lib/busy.ts"
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Singletons built once by the entry and handed to every module.
|
|
6
|
-
* Derived from the event bus, so they must have exactly one subscription -- a per-module
|
|
7
|
-
* copy would be N subscriptions maintaining N identical copies of the same state.
|
|
8
|
-
*/
|
|
9
|
-
export interface SharedDeps {
|
|
10
|
-
/** live per-session busy/idle state; the entry owns the subscription that feeds it */
|
|
11
|
-
busy: BusyTracker
|
|
12
|
-
/**
|
|
13
|
-
* Declared tool name -> the name the model was actually offered (see `toolNames` config).
|
|
14
|
-
* Needed wherever a module names one of its own tools in text the model reads: under a
|
|
15
|
-
* remap the declared name is not a tool the model has.
|
|
16
|
-
*/
|
|
17
|
-
toolName(declared: string): string
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/** Per-feature config from .opencode/overclock.json. `false` = off, object = options. */
|
|
21
|
-
export type FeatureConfig = boolean | Record<string, unknown>
|
|
22
|
-
|
|
23
|
-
/** A problem found in overclock.json. Lives here so config consumers need not import validate. */
|
|
24
|
-
export interface ConfigIssue {
|
|
25
|
-
/** dotted location in overclock.json, e.g. "features.tasks.killOnExit" */
|
|
26
|
-
path: string
|
|
27
|
-
message: string
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/** Option value kinds a module declares, so a typo in overclock.json can be caught. */
|
|
31
|
-
export type OptionType = "boolean" | "number" | "string" | "array" | "object"
|
|
32
|
-
|
|
33
|
-
export interface OverclockConfig {
|
|
34
|
-
features?: Record<string, FeatureConfig>
|
|
35
|
-
/**
|
|
36
|
-
* Model-visible tool ids: declared name -> replacement. The key of the `tool` hook map is
|
|
37
|
-
* literally the name sent to the provider, so this is the whole remap. Exists for hosts
|
|
38
|
-
* behind a proxy that whitelists tool names and rejects unknown ones.
|
|
39
|
-
*
|
|
40
|
-
* A replacement that collides with a built-in tool *overrides* that built-in in the final
|
|
41
|
-
* tool map -- borrow a name you do not mind losing. Permission ids are deliberately not
|
|
42
|
-
* remapped: they key the user's opencode permission config, not the wire format.
|
|
43
|
-
*/
|
|
44
|
-
toolNames?: Record<string, string>
|
|
45
|
-
/**
|
|
46
|
-
* The only tool names the model may be offered. Entries are literal names or the name of a
|
|
47
|
-
* bundled list (see KNOWN_ALLOWLISTS), so extending one is `["claude-code", "MyExtraTool"]`.
|
|
48
|
-
* A bundled list also supplies default aliases for this plugin's tools; `toolNames` overrides
|
|
49
|
-
* those per tool.
|
|
50
|
-
*
|
|
51
|
-
* Any tool whose final name is not permitted is withheld from the model rather than offered
|
|
52
|
-
* and rejected -- a single unrecognised name can fail a whole request, so a loud gap at
|
|
53
|
-
* startup beats a session that cannot reach the provider.
|
|
54
|
-
*/
|
|
55
|
-
toolAllowlist?: string[] | string
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* One feature = one module.
|
|
60
|
-
* init returns partial Hooks. Same hook from many modules -> composed in registry order.
|
|
61
|
-
*/
|
|
62
|
-
export interface FeatureModule {
|
|
63
|
-
name: string
|
|
64
|
-
/** on by default? */
|
|
65
|
-
defaultEnabled: boolean
|
|
66
|
-
/** SDK client surfaces (dot-paths) the module needs. Missing -> module skipped + warn. */
|
|
67
|
-
requires?: string[]
|
|
68
|
-
/** Tool names registered. Declared, not derived -- feeds the first-run summary. */
|
|
69
|
-
tools?: string[]
|
|
70
|
-
/** Accepted option keys -> expected type. Anything else in config is a typo. */
|
|
71
|
-
options?: Record<string, OptionType>
|
|
72
|
-
init(ctx: PluginInput, options: Record<string, unknown>, shared: SharedDeps): Promise<Partial<Hooks>>
|
|
73
|
-
}
|
|
1
|
+
export * from "./core/types.ts"
|