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.
Files changed (50) hide show
  1. package/README.md +285 -81
  2. package/package.json +3 -3
  3. package/src/bridge.ts +1 -0
  4. package/src/buddy/companion.ts +104 -5
  5. package/src/buddy/sprites.ts +4 -4
  6. package/src/buddy/tui.ts +175 -65
  7. package/src/core/bridge.ts +34 -0
  8. package/src/core/lifecycle.ts +53 -0
  9. package/src/core/policy.ts +128 -0
  10. package/src/core/summary.ts +33 -0
  11. package/src/core/types.ts +164 -0
  12. package/src/features/buddy.ts +1 -2
  13. package/src/features/guard.ts +168 -30
  14. package/src/features/index.ts +6 -4
  15. package/src/features/recovery.ts +143 -0
  16. package/src/features/sched.ts +147 -89
  17. package/src/features/tasks.ts +54 -20
  18. package/src/features/truncator.ts +99 -0
  19. package/src/features/usage.ts +26 -65
  20. package/src/index.ts +98 -55
  21. package/src/lib/busy.ts +1 -25
  22. package/src/lib/exec.ts +7 -0
  23. package/src/lib/inject.ts +10 -56
  24. package/src/lib/mirror.ts +13 -0
  25. package/src/lib/probe.ts +1 -15
  26. package/src/lib/state.ts +10 -39
  27. package/src/lib/tmux.ts +1 -0
  28. package/src/lib/ui.ts +208 -0
  29. package/src/merge.ts +2 -35
  30. package/src/platform/probe.ts +25 -0
  31. package/src/platform/process/exec.ts +76 -0
  32. package/src/platform/process/tmux.ts +60 -0
  33. package/src/platform/session/busy.ts +33 -0
  34. package/src/platform/session/inject.ts +82 -0
  35. package/src/platform/session/notify.ts +20 -0
  36. package/src/platform/storage/state.ts +77 -0
  37. package/src/platform/storage/store.ts +61 -0
  38. package/src/summary.ts +1 -0
  39. package/src/tools.ts +8 -0
  40. package/src/tui.ts +57 -186
  41. package/src/types.ts +1 -39
  42. package/src/v2/context.ts +470 -0
  43. package/src/v2/host.ts +117 -0
  44. package/src/v2/loader.ts +150 -0
  45. package/src/buddy/reactions.ts +0 -41
  46. package/src/buddy/types.ts +0 -30
  47. package/src/config.ts +0 -19
  48. package/src/features/checkpoints.ts +0 -128
  49. package/src/features/sandbox.ts +0 -104
  50. 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
- import type { Hooks } from "@opencode-ai/plugin"
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"