opencode-overclock 0.2.2 → 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 +285 -81
- package/package.json +3 -3
- 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 +54 -20
- package/src/features/truncator.ts +99 -0
- package/src/features/usage.ts +26 -65
- package/src/index.ts +98 -55
- 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 -35
- 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 -0
- package/src/tui.ts +57 -186
- package/src/types.ts +1 -39
- 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 -143
package/src/lib/ui.ts
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import type { TuiPluginApi, TuiSlotContext } from "@opencode-ai/plugin/tui"
|
|
2
|
+
import { missingSurfaces } from "./probe.ts"
|
|
3
|
+
import type { Store } from "./state.ts"
|
|
4
|
+
|
|
5
|
+
/** Raw `@opentui/solid` node factory. Returns a JSX.Element the host can render. */
|
|
6
|
+
type JsxFactory = (type: string, props?: Record<string, unknown> | null) => unknown
|
|
7
|
+
|
|
8
|
+
type SlotRender = (ctx: TuiSlotContext) => unknown
|
|
9
|
+
|
|
10
|
+
export interface UiCommand {
|
|
11
|
+
/** command palette entry */
|
|
12
|
+
title: string
|
|
13
|
+
/** slash name, without the leading "/" */
|
|
14
|
+
slash?: string
|
|
15
|
+
/** optional slash aliases */
|
|
16
|
+
aliases?: string[]
|
|
17
|
+
/** unique command id; defaults to `overclock.<slash ?? title>` */
|
|
18
|
+
id?: string
|
|
19
|
+
run(dialog?: unknown): void | Promise<void>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface UiNotify {
|
|
23
|
+
message: string
|
|
24
|
+
title?: string
|
|
25
|
+
/** built-in sound name; only played when the terminal is blurred */
|
|
26
|
+
sound?: "default" | "question" | "permission" | "error" | "done" | "subagent_done"
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Thin, failure-tolerant facade over `TuiPluginApi`.
|
|
31
|
+
*
|
|
32
|
+
* Two things it buys us. First, *uniform degradation*: every host call is a place the TUI API can
|
|
33
|
+
* drift out from under us, and a throw inside plugin setup takes down every later registration in
|
|
34
|
+
* the same function. Each method here is individually guarded and warns with a consistent label,
|
|
35
|
+
* so one broken surface costs exactly one feature. Second, *disposal by construction*: the host
|
|
36
|
+
* hands back an unsubscribe from `event.on`, an id from `slots.register` (where cleanup is host-managed), and
|
|
37
|
+
* nothing from `setInterval` -- contracts that call sites previously tracked
|
|
38
|
+
* by hand. Listeners and intervals registered through the facade are wired to `lifecycle.onDispose` here.
|
|
39
|
+
*/
|
|
40
|
+
export interface Ui {
|
|
41
|
+
/** escape hatch for surfaces the facade does not wrap yet */
|
|
42
|
+
readonly api: TuiPluginApi
|
|
43
|
+
/** project directory; the root every `Store` path is resolved against */
|
|
44
|
+
readonly directory: string
|
|
45
|
+
/** subscribe to a host event, auto-disposed. Returns the unsubscribe for early removal. */
|
|
46
|
+
readonly on: TuiPluginApi["event"]["on"]
|
|
47
|
+
/** register a palette/slash command, auto-disposed */
|
|
48
|
+
command(cmd: UiCommand): void
|
|
49
|
+
/** desktop notification + sound, fired only when the terminal is blurred */
|
|
50
|
+
notify(input: UiNotify): void
|
|
51
|
+
toast(message: string, variant?: "info" | "success" | "warning" | "error"): void
|
|
52
|
+
/** setInterval, cleared on dispose */
|
|
53
|
+
every(ms: number, fn: () => void): void
|
|
54
|
+
/** read a store defined in `platform/storage/store.ts` (or re-exported via `mirror.ts`) */
|
|
55
|
+
read<T>(store: Store<T>): Promise<T>
|
|
56
|
+
/** as `read`, but `undefined` when the server half has never written the file */
|
|
57
|
+
readOptional<T>(store: Store<T>): Promise<T | undefined>
|
|
58
|
+
/** true when every dot-path resolves to a function on the host api */
|
|
59
|
+
has(...paths: string[]): boolean
|
|
60
|
+
/**
|
|
61
|
+
* Load the host's JSX runtime, enabling `node()`. Separate from `createUi` because
|
|
62
|
+
* `@opentui/solid` is an optional peer and slot rendering is synchronous: a feature that draws
|
|
63
|
+
* must await this up front, and one that does not should not pay for the import.
|
|
64
|
+
*/
|
|
65
|
+
enableJsx(): Promise<void>
|
|
66
|
+
/** create a renderable node. Throws unless `enableJsx()` resolved first. */
|
|
67
|
+
node(type: string, props: Record<string, unknown>): unknown
|
|
68
|
+
/** mount slot renderers. The host owns slot cleanup, so there is nothing to dispose. */
|
|
69
|
+
slots(map: Record<string, SlotRender>): void
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function warn(label: string, e: unknown): void {
|
|
73
|
+
console.warn(`[overclock-tui] ${label} failed: ${e}`)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Run `fn`, swallowing and reporting any throw. Returns undefined on failure. */
|
|
77
|
+
function guard<T>(label: string, fn: () => T): T | undefined {
|
|
78
|
+
try {
|
|
79
|
+
return fn()
|
|
80
|
+
} catch (e) {
|
|
81
|
+
warn(label, e)
|
|
82
|
+
return undefined
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function createUi(api: TuiPluginApi): Ui {
|
|
87
|
+
const directory = api.state.path.directory
|
|
88
|
+
let jsx: JsxFactory | undefined
|
|
89
|
+
|
|
90
|
+
/** Best-effort disposer registration -- `lifecycle` itself is a surface that can be missing. */
|
|
91
|
+
const onDispose = (fn: () => void): void => {
|
|
92
|
+
try {
|
|
93
|
+
api.lifecycle.onDispose(async () => fn())
|
|
94
|
+
} catch (e) {
|
|
95
|
+
warn("lifecycle registration", e)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const noop = () => {}
|
|
100
|
+
|
|
101
|
+
// Cast: the host's `on` is generic over the event union, and reproducing that generic on an
|
|
102
|
+
// ordinary function expression is not expressible without naming `Event`, which
|
|
103
|
+
// `@opencode-ai/plugin/tui` does not re-export. The cast keeps full narrowing for callers.
|
|
104
|
+
const on = ((type: string, handler: (event: never) => void) => {
|
|
105
|
+
const unsub = guard(`subscribe ${type}`, () =>
|
|
106
|
+
(api.event.on as (t: string, h: (event: never) => void) => () => void)(type, handler),
|
|
107
|
+
)
|
|
108
|
+
if (!unsub) return noop
|
|
109
|
+
onDispose(unsub)
|
|
110
|
+
return unsub
|
|
111
|
+
}) as TuiPluginApi["event"]["on"]
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
api,
|
|
115
|
+
directory,
|
|
116
|
+
on,
|
|
117
|
+
|
|
118
|
+
command(cmd) {
|
|
119
|
+
const id = cmd.id ?? `overclock.${cmd.slash ?? cmd.title}`
|
|
120
|
+
// `api.command` is deprecated upstream in favour of `keymap.registerLayer({commands,
|
|
121
|
+
// bindings})`, and optional on the api type. Isolated here so that migration is a change
|
|
122
|
+
// to this one method rather than to every feature that registers a command.
|
|
123
|
+
const unregister = guard(`command ${id}`, () =>
|
|
124
|
+
api.command?.register(() => [
|
|
125
|
+
{
|
|
126
|
+
title: cmd.title,
|
|
127
|
+
value: id,
|
|
128
|
+
...(cmd.slash
|
|
129
|
+
? {
|
|
130
|
+
slash: {
|
|
131
|
+
name: cmd.slash,
|
|
132
|
+
...(cmd.aliases ? { aliases: cmd.aliases } : {}),
|
|
133
|
+
},
|
|
134
|
+
}
|
|
135
|
+
: {}),
|
|
136
|
+
onSelect: async (dialog) => {
|
|
137
|
+
try {
|
|
138
|
+
await cmd.run(dialog)
|
|
139
|
+
} catch (e) {
|
|
140
|
+
warn(`command ${id}`, e)
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
]),
|
|
145
|
+
)
|
|
146
|
+
if (unregister) onDispose(unregister)
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
notify(input) {
|
|
150
|
+
guard("notify", () =>
|
|
151
|
+
api.attention.notify({
|
|
152
|
+
title: input.title ?? "opencode",
|
|
153
|
+
message: input.message,
|
|
154
|
+
notification: { when: "blurred" },
|
|
155
|
+
...(input.sound ? { sound: { name: input.sound, when: "blurred" as const } } : {}),
|
|
156
|
+
}),
|
|
157
|
+
)
|
|
158
|
+
},
|
|
159
|
+
|
|
160
|
+
toast(message, variant) {
|
|
161
|
+
guard("toast", () => api.ui.toast({ message, ...(variant ? { variant } : {}) }))
|
|
162
|
+
},
|
|
163
|
+
|
|
164
|
+
every(ms, fn) {
|
|
165
|
+
const timer = setInterval(() => {
|
|
166
|
+
try {
|
|
167
|
+
fn()
|
|
168
|
+
} catch (e) {
|
|
169
|
+
warn("interval", e)
|
|
170
|
+
}
|
|
171
|
+
}, ms)
|
|
172
|
+
onDispose(() => clearInterval(timer))
|
|
173
|
+
},
|
|
174
|
+
|
|
175
|
+
read(store) {
|
|
176
|
+
return store.read(directory)
|
|
177
|
+
},
|
|
178
|
+
|
|
179
|
+
readOptional(store) {
|
|
180
|
+
return store.readMaybe(directory)
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
has(...paths) {
|
|
184
|
+
return missingSurfaces(api, paths).length === 0
|
|
185
|
+
},
|
|
186
|
+
|
|
187
|
+
async enableJsx() {
|
|
188
|
+
// The host must map this specifier to *its own* solid instance. If it does not, the import
|
|
189
|
+
// throws here -- at the caller's await, where it can degrade to "no drawing" -- rather than
|
|
190
|
+
// from inside a slot render where nothing can catch it.
|
|
191
|
+
const mod = (await import("@opentui/solid/jsx-runtime")) as unknown as { jsx: JsxFactory }
|
|
192
|
+
jsx = mod.jsx
|
|
193
|
+
},
|
|
194
|
+
|
|
195
|
+
node(type, props) {
|
|
196
|
+
if (!jsx) throw new Error("ui.node() requires await ui.enableJsx()")
|
|
197
|
+
return jsx(type, props)
|
|
198
|
+
},
|
|
199
|
+
|
|
200
|
+
slots(map) {
|
|
201
|
+
guard("slot registration", () =>
|
|
202
|
+
api.slots.register({
|
|
203
|
+
slots: map as unknown as Parameters<TuiPluginApi["slots"]["register"]>[0]["slots"],
|
|
204
|
+
}),
|
|
205
|
+
)
|
|
206
|
+
},
|
|
207
|
+
}
|
|
208
|
+
}
|
package/src/merge.ts
CHANGED
|
@@ -1,35 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Compose many Partial<Hooks> into one Hooks.
|
|
5
|
-
* - fn hooks: call sequentially, module order. Each sees prior mutations of `output`.
|
|
6
|
-
* - `tool` map: shallow merge. Name collision -> later module wins, warn.
|
|
7
|
-
*/
|
|
8
|
-
export function mergeHooks(parts: Partial<Hooks>[]): Hooks {
|
|
9
|
-
const merged: Record<string, unknown> = {}
|
|
10
|
-
const tools: Record<string, unknown> = {}
|
|
11
|
-
|
|
12
|
-
for (const part of parts) {
|
|
13
|
-
for (const [key, value] of Object.entries(part)) {
|
|
14
|
-
if (value === undefined) continue
|
|
15
|
-
if (key === "tool") {
|
|
16
|
-
for (const [name, def] of Object.entries(value as Record<string, unknown>)) {
|
|
17
|
-
if (tools[name]) console.warn(`[overclock] tool collision: ${name} (later module wins)`)
|
|
18
|
-
tools[name] = def
|
|
19
|
-
}
|
|
20
|
-
continue
|
|
21
|
-
}
|
|
22
|
-
const prev = merged[key] as ((...a: unknown[]) => Promise<void>) | undefined
|
|
23
|
-
const next = value as (...a: unknown[]) => Promise<void>
|
|
24
|
-
merged[key] = prev
|
|
25
|
-
? async (...args: unknown[]) => {
|
|
26
|
-
await prev(...args)
|
|
27
|
-
await next(...args)
|
|
28
|
-
}
|
|
29
|
-
: next
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
if (Object.keys(tools).length) merged.tool = tools
|
|
34
|
-
return merged as Hooks
|
|
35
|
-
}
|
|
1
|
+
export { mergeHooks } from "./core/lifecycle.ts"
|
|
2
|
+
export { applyToolPolicy, EMPTY_POLICY, renameInText, type ToolPolicy } from "./core/policy.ts"
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime surface probe (doc: "probe for the surfaces we depend on ... instead of
|
|
3
|
+
* failing silently when upstream moves"). Dot-paths resolved against the SDK client;
|
|
4
|
+
* leaf must be a function by default.
|
|
5
|
+
*/
|
|
6
|
+
export function missingSurfaces(
|
|
7
|
+
client: unknown,
|
|
8
|
+
paths: string[],
|
|
9
|
+
requiredType: "function" | "object" | "defined" = "function",
|
|
10
|
+
): string[] {
|
|
11
|
+
return paths.filter((path) => {
|
|
12
|
+
let node: unknown = client
|
|
13
|
+
for (const key of path.split(".")) {
|
|
14
|
+
if (node == null || typeof node !== "object") return true
|
|
15
|
+
try {
|
|
16
|
+
node = (node as Record<string, unknown>)[key]
|
|
17
|
+
} catch {
|
|
18
|
+
return true
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
if (requiredType === "function") return typeof node !== "function"
|
|
22
|
+
if (requiredType === "object") return node === null || typeof node !== "object"
|
|
23
|
+
return node === undefined
|
|
24
|
+
})
|
|
25
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POSIX single-quote escape for safe shell argument interpolation.
|
|
3
|
+
*/
|
|
4
|
+
export function shellQuote(s: string): string {
|
|
5
|
+
return `'${s.replace(/'/g, `'\\''`)}'`
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export const NON_INTERACTIVE_ENV: Record<string, string> = {
|
|
9
|
+
GIT_PAGER: "cat",
|
|
10
|
+
PAGER: "cat",
|
|
11
|
+
GIT_EDITOR: "true",
|
|
12
|
+
CI: "true",
|
|
13
|
+
DEBIAN_FRONTEND: "noninteractive",
|
|
14
|
+
TERM: "dumb",
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ExecBashOptions {
|
|
18
|
+
cwd?: string
|
|
19
|
+
env?: Record<string, string | undefined>
|
|
20
|
+
timeoutMs?: number
|
|
21
|
+
onSpawn?: (proc: Bun.Subprocess) => void
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ExecBashResult {
|
|
25
|
+
code: number | null
|
|
26
|
+
stdout: string
|
|
27
|
+
stderr: string
|
|
28
|
+
combined: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Run a command via `bash -c`, capturing stdout, stderr, and exit code.
|
|
33
|
+
* Ensures non-interactive environment variables with correct precedence and
|
|
34
|
+
* process termination escalation.
|
|
35
|
+
*/
|
|
36
|
+
export async function execBash(command: string, options: ExecBashOptions = {}): Promise<ExecBashResult> {
|
|
37
|
+
const mergedEnv: Record<string, string | undefined> = {
|
|
38
|
+
...process.env,
|
|
39
|
+
...NON_INTERACTIVE_ENV,
|
|
40
|
+
...options.env,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const proc = Bun.spawn(["bash", "-c", command], {
|
|
44
|
+
cwd: options.cwd,
|
|
45
|
+
env: mergedEnv,
|
|
46
|
+
stdout: "pipe",
|
|
47
|
+
stderr: "pipe",
|
|
48
|
+
})
|
|
49
|
+
options.onSpawn?.(proc)
|
|
50
|
+
|
|
51
|
+
let killEscalationTimer: ReturnType<typeof setTimeout> | undefined
|
|
52
|
+
const killTimer = options.timeoutMs
|
|
53
|
+
? setTimeout(() => {
|
|
54
|
+
try {
|
|
55
|
+
proc.kill("SIGTERM")
|
|
56
|
+
} catch {}
|
|
57
|
+
killEscalationTimer = setTimeout(() => {
|
|
58
|
+
try {
|
|
59
|
+
proc.kill("SIGKILL")
|
|
60
|
+
} catch {}
|
|
61
|
+
}, 2000)
|
|
62
|
+
killEscalationTimer.unref?.()
|
|
63
|
+
}, options.timeoutMs)
|
|
64
|
+
: undefined
|
|
65
|
+
|
|
66
|
+
const [stdout, stderr, code] = await Promise.all([
|
|
67
|
+
new Response(proc.stdout).text(),
|
|
68
|
+
new Response(proc.stderr).text(),
|
|
69
|
+
proc.exited,
|
|
70
|
+
])
|
|
71
|
+
|
|
72
|
+
if (killTimer) clearTimeout(killTimer)
|
|
73
|
+
if (killEscalationTimer) clearTimeout(killEscalationTimer)
|
|
74
|
+
|
|
75
|
+
return { code, stdout, stderr, combined: stdout + stderr }
|
|
76
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
export interface TmuxPane {
|
|
2
|
+
paneId: string
|
|
3
|
+
close: () => Promise<void>
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/** Check if current process is running inside a tmux session. */
|
|
7
|
+
export function isInsideTmux(): boolean {
|
|
8
|
+
return Boolean(process.env.TMUX)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Spawns a background tmux split pane tailing the task log file.
|
|
13
|
+
* Uses `-d` to avoid stealing terminal focus.
|
|
14
|
+
* Uses positional parameters to prevent shell injection via title formatting.
|
|
15
|
+
*/
|
|
16
|
+
export async function spawnTaskPane(logPath: string, title?: string): Promise<TmuxPane | null> {
|
|
17
|
+
if (!isInsideTmux()) return null
|
|
18
|
+
try {
|
|
19
|
+
const args = title
|
|
20
|
+
? [
|
|
21
|
+
"tmux",
|
|
22
|
+
"split-window",
|
|
23
|
+
"-d",
|
|
24
|
+
"-P",
|
|
25
|
+
"-F",
|
|
26
|
+
"#{pane_id}",
|
|
27
|
+
"bash",
|
|
28
|
+
"-c",
|
|
29
|
+
'printf "=== [%s] ===\\n" "$1" && exec tail -f "$2"',
|
|
30
|
+
"_",
|
|
31
|
+
title,
|
|
32
|
+
logPath,
|
|
33
|
+
]
|
|
34
|
+
: ["tmux", "split-window", "-d", "-P", "-F", "#{pane_id}", "tail", "-f", logPath]
|
|
35
|
+
|
|
36
|
+
const proc = Bun.spawn(args, { stdout: "pipe", stderr: "pipe" })
|
|
37
|
+
const [stdout, code] = await Promise.all([new Response(proc.stdout).text(), proc.exited])
|
|
38
|
+
if (code !== 0) return null
|
|
39
|
+
|
|
40
|
+
const paneId = stdout.trim()
|
|
41
|
+
if (!paneId.startsWith("%")) return null
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
paneId,
|
|
45
|
+
close: async () => {
|
|
46
|
+
try {
|
|
47
|
+
const killProc = Bun.spawn(["tmux", "kill-pane", "-t", paneId], {
|
|
48
|
+
stdout: "ignore",
|
|
49
|
+
stderr: "ignore",
|
|
50
|
+
})
|
|
51
|
+
await killProc.exited
|
|
52
|
+
} catch {
|
|
53
|
+
// Pane might have been manually closed by user
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
}
|
|
57
|
+
} catch {
|
|
58
|
+
return null
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -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,82 @@
|
|
|
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
|
+
await client.session.promptAsync({
|
|
69
|
+
path: { id: sessionID },
|
|
70
|
+
body: {
|
|
71
|
+
parts: [{ type: "text", text }],
|
|
72
|
+
...(ctx.model ? { model: ctx.model } : {}),
|
|
73
|
+
...(ctx.agent ? { agent: ctx.agent } : {}),
|
|
74
|
+
...(options?.noReply ? { noReply: true } : {}),
|
|
75
|
+
},
|
|
76
|
+
})
|
|
77
|
+
return true
|
|
78
|
+
} catch (e) {
|
|
79
|
+
console.warn(`[overclock] inject failed (session ${sessionID}): ${e}`)
|
|
80
|
+
return false
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -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,77 @@
|
|
|
1
|
+
import { mkdir } from "node:fs/promises"
|
|
2
|
+
import { shellQuote } from "../process/exec.ts"
|
|
3
|
+
|
|
4
|
+
export { shellQuote }
|
|
5
|
+
|
|
6
|
+
/** State root: <project>/.opencode/overclock/[sub]. Pure -- creates nothing. */
|
|
7
|
+
export function stateDir(directory: string, sub?: string): string {
|
|
8
|
+
return `${directory}/.opencode/overclock${sub ? `/${sub}` : ""}`
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** State root: <project>/.opencode/overclock/[sub]. Creates if missing. */
|
|
12
|
+
export async function ensureStateDir(directory: string, sub?: string): Promise<string> {
|
|
13
|
+
const dir = stateDir(directory, sub)
|
|
14
|
+
await mkdir(dir, { recursive: true })
|
|
15
|
+
return dir
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* True once per project, then never again. Marker lives beside the other state, so
|
|
20
|
+
* deleting .opencode/overclock/ re-arms the first-run notice.
|
|
21
|
+
*/
|
|
22
|
+
export async function firstRun(directory: string): Promise<boolean> {
|
|
23
|
+
const dir = await ensureStateDir(directory)
|
|
24
|
+
const marker = Bun.file(`${dir}/.installed`)
|
|
25
|
+
if (await marker.exists()) return false
|
|
26
|
+
await Bun.write(marker, new Date().toISOString())
|
|
27
|
+
return true
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function readJson<T>(path: string, fallback: T): Promise<T> {
|
|
31
|
+
const file = Bun.file(path)
|
|
32
|
+
if (!(await file.exists())) return fallback
|
|
33
|
+
try {
|
|
34
|
+
return (await file.json()) as T
|
|
35
|
+
} catch {
|
|
36
|
+
return fallback
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Serializes the value formatted with 2 spaces and writes to the destination path.
|
|
42
|
+
*/
|
|
43
|
+
export async function writeJson(path: string, value: unknown): Promise<void> {
|
|
44
|
+
await Bun.write(path, JSON.stringify(value, null, 2))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A typed state file accessor. */
|
|
48
|
+
export interface Store<T> {
|
|
49
|
+
readonly file: string
|
|
50
|
+
path(directory: string): string
|
|
51
|
+
read(directory: string): Promise<T>
|
|
52
|
+
readMaybe(directory: string): Promise<T | undefined>
|
|
53
|
+
write(directory: string, value: T): Promise<void>
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Factory for typed state file stores. */
|
|
57
|
+
export function defineStore<T>(file: string, fallback: () => T): Store<T> {
|
|
58
|
+
const path = (directory: string) => `${stateDir(directory)}/${file}`
|
|
59
|
+
return {
|
|
60
|
+
file,
|
|
61
|
+
path,
|
|
62
|
+
read: (directory) => readJson<T>(path(directory), fallback()),
|
|
63
|
+
readMaybe: async (directory) => {
|
|
64
|
+
const target = path(directory)
|
|
65
|
+
try {
|
|
66
|
+
if (!(await Bun.file(target).exists())) return undefined
|
|
67
|
+
} catch {
|
|
68
|
+
return undefined
|
|
69
|
+
}
|
|
70
|
+
return readJson<T>(target, fallback())
|
|
71
|
+
},
|
|
72
|
+
write: async (directory, value) => {
|
|
73
|
+
await ensureStateDir(directory)
|
|
74
|
+
await writeJson(path(directory), value)
|
|
75
|
+
},
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -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"
|