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.
Files changed (82) hide show
  1. package/README.md +252 -111
  2. package/package.json +6 -4
  3. package/skills/codebase-design/DEEPENING.md +35 -0
  4. package/skills/codebase-design/DESIGN-IT-TWICE.md +34 -0
  5. package/skills/codebase-design/SKILL.md +93 -0
  6. package/skills/diagnosing-bugs/SKILL.md +123 -0
  7. package/skills/domain-modeling/ADR-FORMAT.md +55 -0
  8. package/skills/domain-modeling/CONTEXT-FORMAT.md +32 -0
  9. package/skills/domain-modeling/SKILL.md +102 -0
  10. package/skills/doubt/SKILL.md +80 -0
  11. package/skills/grilling/SKILL.md +96 -0
  12. package/skills/source-discipline/SKILL.md +78 -0
  13. package/skills/tdd/SKILL.md +87 -0
  14. package/skills/to-spec/SKILL.md +69 -0
  15. package/skills/to-spec/SPEC-TEMPLATE.md +50 -0
  16. package/skills/to-tickets/SKILL.md +74 -0
  17. package/skills/to-tickets/TICKET-TEMPLATE.md +41 -0
  18. package/src/bridge.ts +1 -0
  19. package/src/buddy/companion.ts +104 -5
  20. package/src/buddy/sprites.ts +4 -4
  21. package/src/buddy/tui.ts +175 -65
  22. package/src/core/bridge.ts +34 -0
  23. package/src/core/lifecycle.ts +67 -0
  24. package/src/core/policy.ts +128 -0
  25. package/src/core/summary.ts +33 -0
  26. package/src/core/types.ts +193 -0
  27. package/src/features/buddy.ts +1 -2
  28. package/src/features/guard.ts +421 -37
  29. package/src/features/index.ts +18 -4
  30. package/src/features/recovery.ts +153 -0
  31. package/src/features/safety.ts +147 -0
  32. package/src/features/sched.ts +183 -89
  33. package/src/features/tasks.ts +134 -33
  34. package/src/features/truncator.ts +116 -0
  35. package/src/features/usage.ts +46 -65
  36. package/src/features/workflow.ts +256 -0
  37. package/src/index.ts +96 -67
  38. package/src/lib/busy.ts +1 -25
  39. package/src/lib/exec.ts +13 -0
  40. package/src/lib/inject.ts +10 -56
  41. package/src/lib/mirror.ts +13 -0
  42. package/src/lib/probe.ts +1 -15
  43. package/src/lib/state.ts +10 -39
  44. package/src/lib/tmux.ts +1 -0
  45. package/src/lib/ui.ts +208 -0
  46. package/src/merge.ts +2 -66
  47. package/src/platform/probe.ts +25 -0
  48. package/src/platform/process/exec.ts +317 -0
  49. package/src/platform/process/tmux.ts +60 -0
  50. package/src/platform/session/busy.ts +33 -0
  51. package/src/platform/session/inject.ts +89 -0
  52. package/src/platform/session/notify.ts +20 -0
  53. package/src/platform/storage/state.ts +99 -0
  54. package/src/platform/storage/store.ts +61 -0
  55. package/src/summary.ts +1 -0
  56. package/src/tools.ts +8 -244
  57. package/src/tui.ts +57 -186
  58. package/src/types.ts +1 -73
  59. package/src/v2/context.ts +470 -0
  60. package/src/v2/host.ts +120 -0
  61. package/src/v2/loader.ts +150 -0
  62. package/src/workflow/agents/codebase-researcher.ts +27 -0
  63. package/src/workflow/agents/design-explorer.ts +33 -0
  64. package/src/workflow/agents/doubt-reviewer.ts +26 -0
  65. package/src/workflow/agents/engineering-coach.ts +23 -0
  66. package/src/workflow/agents/performance-auditor.ts +29 -0
  67. package/src/workflow/agents/security-auditor.ts +23 -0
  68. package/src/workflow/agents/spec-reviewer.ts +15 -0
  69. package/src/workflow/agents/standards-reviewer.ts +24 -0
  70. package/src/workflow/agents/test-engineer.ts +28 -0
  71. package/src/workflow/catalog.ts +210 -0
  72. package/src/workflow/templates/build.ts +47 -0
  73. package/src/workflow/templates/define.ts +45 -0
  74. package/src/workflow/templates/diagnose.ts +58 -0
  75. package/src/workflow/templates/plan.ts +52 -0
  76. package/src/workflow/templates/ship.ts +64 -0
  77. package/src/buddy/reactions.ts +0 -41
  78. package/src/buddy/types.ts +0 -30
  79. package/src/config.ts +0 -19
  80. package/src/features/checkpoints.ts +0 -128
  81. package/src/features/sandbox.ts +0 -104
  82. package/src/validate.ts +0 -197
@@ -2,7 +2,10 @@ import { stat } from "node:fs/promises"
2
2
  import { tool } from "@opencode-ai/plugin"
3
3
  import type { FeatureModule } from "../types.ts"
4
4
  import { ensureStateDir, shellQuote, writeJson } from "../lib/state.ts"
5
+ import { taskStore, type TaskMirrorEntry } from "../lib/mirror.ts"
5
6
  import { inject, toast } from "../lib/inject.ts"
7
+ import { killProcessTree, NON_INTERACTIVE_ENV, redactSensitiveOutput, sanitizeEnv } from "../lib/exec.ts"
8
+ import { spawnTaskPane, type TmuxPane } from "../lib/tmux.ts"
6
9
 
7
10
  const z = tool.schema
8
11
 
@@ -16,6 +19,22 @@ const PROMPT_PATTERNS = [
16
19
  /Overwrite\?/i,
17
20
  ]
18
21
 
22
+ const INTERACTIVE_COMMAND_PATTERNS = [
23
+ /^\s*(?:vi|vim|nvim|nano|pico|emacs)\b/i,
24
+ /^\s*git\s+(?:rebase\s+-i|commit\s+--amend(?!\s+-m))/i,
25
+ /^\s*git\s+add\s+-p\b/i,
26
+ /^\s*(?:python|python3|node|irb|ghci|bash|sh|zsh)\s*$/i,
27
+ ]
28
+
29
+ /** Detects if a command is explicitly interactive (e.g. editor, rebase -i, bare REPL). */
30
+ export function detectInteractiveCommand(command: string): string | null {
31
+ for (const pattern of INTERACTIVE_COMMAND_PATTERNS) {
32
+ const match = command.match(pattern)
33
+ if (match) return match[0].trim()
34
+ }
35
+ return null
36
+ }
37
+
19
38
  /** Last non-empty line of `tail` looks like an interactive y/n or press-key prompt. */
20
39
  export function looksLikePrompt(tail: string): boolean {
21
40
  const lastLine = tail.trimEnd().split("\n").pop() ?? ""
@@ -39,14 +58,7 @@ interface TaskEntry extends TaskRecord {
39
58
  stallTimer?: ReturnType<typeof setInterval>
40
59
  stallNotified: boolean
41
60
  startedAt: number
42
- }
43
-
44
- interface TaskMirrorEntry {
45
- id: string
46
- description: string
47
- status: TaskRecord["status"]
48
- exitCode: number | null
49
- startedAt: number
61
+ tmuxPane?: TmuxPane
50
62
  }
51
63
 
52
64
  export interface TaskManager {
@@ -95,7 +107,8 @@ function startStallWatchdog(
95
107
  lastGrowth = Date.now()
96
108
  return
97
109
  }
98
- if (Date.now() - lastGrowth < thresholdMs || entry.stallNotified) return
110
+ if (Date.now() - lastGrowth < thresholdMs || entry.stallNotified || entry.status !== "running")
111
+ return
99
112
  const file = Bun.file(entry.logPath)
100
113
  const start = Math.max(0, s.size - tailBytes)
101
114
  const tail = await file.slice(start).text()
@@ -114,20 +127,55 @@ function startStallWatchdog(
114
127
  }, checkIntervalMs)
115
128
  }
116
129
 
130
+ export async function readLogTail(logPath: string, tailLines = 50): Promise<string> {
131
+ const file = Bun.file(logPath)
132
+ if (!(await file.exists())) return "(no output)"
133
+ const s = await stat(logPath).catch(() => null)
134
+ if (!s || s.size === 0) return "(no output)"
135
+
136
+ const effectiveTail = Math.max(1, tailLines)
137
+ // Cap read window to last 512KB to prevent memory exhaustion on giant log files
138
+ const maxBytes = 512 * 1024
139
+ const start = Math.max(0, s.size - maxBytes)
140
+ const text = await (start > 0 ? file.slice(start).text() : file.text())
141
+ const lines = text.trimEnd().split("\n")
142
+ return lines.slice(-effectiveTail).join("\n")
143
+ }
144
+
117
145
  /** Exported for tests. onExit fires after status/exitCode settled. */
118
146
  export function createTaskManager(opts: {
119
147
  logDir: string
120
148
  /** mirror JSON path, written on every state change (spawn/exit/kill); omit to disable */
121
149
  mirrorPath?: string
122
- onExit?: (task: TaskRecord) => void
150
+ onExit?: (task: TaskRecord) => void | Promise<void>
123
151
  /** enables the stall watchdog; absent -> no polling at all */
124
152
  onStall?: (task: TaskRecord, tail: string) => void
125
153
  stallCheckIntervalMs?: number
126
154
  stallThresholdMs?: number
127
155
  stallTailBytes?: number
156
+ /** spawn a tmux split pane to tail task logs (only if TMUX is active) */
157
+ tmux?: boolean
158
+ sanitizeEnv?: boolean
159
+ envAllowlist?: string[]
160
+ maxTasks?: number
128
161
  }): TaskManager {
129
162
  const tasks = new Map<string, TaskEntry>()
130
163
  let counter = 0
164
+ const maxRetainedTasks = opts.maxTasks ?? 100
165
+
166
+ function pruneFinishedTasks(): void {
167
+ if (tasks.size <= maxRetainedTasks) return
168
+ const finished: string[] = []
169
+ for (const [id, entry] of tasks) {
170
+ if (entry.status !== "running") {
171
+ finished.push(id)
172
+ }
173
+ }
174
+ const toRemove = tasks.size - maxRetainedTasks
175
+ for (let i = 0; i < Math.min(toRemove, finished.length); i++) {
176
+ tasks.delete(finished[i]!)
177
+ }
178
+ }
131
179
 
132
180
  function persistMirror(): void {
133
181
  if (!opts.mirrorPath) return
@@ -150,10 +198,38 @@ export function createTaskManager(opts: {
150
198
  }): TaskRecord {
151
199
  const id = `t${(++counter).toString(36)}-${crypto.randomUUID().slice(0, 6)}`
152
200
  const logPath = `${opts.logDir}/${id}.log`
153
- // shell-level redirection: no piping code, survives plugin restart losing streams
154
- const proc = Bun.spawn(["bash", "-c", `(${input.command}) >> ${shellQuote(logPath)} 2>&1`], {
155
- cwd: input.cwd,
156
- })
201
+
202
+ let proc: Bun.Subprocess
203
+ try {
204
+ proc = Bun.spawn(["bash", "-c", `(${input.command}) >> ${shellQuote(logPath)} 2>&1`], {
205
+ cwd: input.cwd,
206
+ env: {
207
+ ...(opts.sanitizeEnv === false ? process.env : sanitizeEnv(process.env, opts.envAllowlist)),
208
+ ...NON_INTERACTIVE_ENV,
209
+ },
210
+ })
211
+ } catch (e) {
212
+ console.warn(`[overclock] failed to spawn task ${id}: ${e}`)
213
+ const failedEntry: TaskEntry = {
214
+ id,
215
+ description: input.description,
216
+ command: input.command,
217
+ cwd: input.cwd,
218
+ sessionID: input.sessionID,
219
+ status: "exited",
220
+ exitCode: 1,
221
+ logPath,
222
+ proc: null as any,
223
+ stallNotified: false,
224
+ startedAt: Date.now(),
225
+ }
226
+ tasks.set(id, failedEntry)
227
+ pruneFinishedTasks()
228
+ persistMirror()
229
+ opts.onExit?.(strip(failedEntry))
230
+ return strip(failedEntry)
231
+ }
232
+
157
233
  const entry: TaskEntry = {
158
234
  id,
159
235
  description: input.description,
@@ -169,6 +245,13 @@ export function createTaskManager(opts: {
169
245
  }
170
246
  tasks.set(id, entry)
171
247
  persistMirror()
248
+ let panePromise: Promise<TmuxPane | null> | undefined
249
+ if (opts.tmux) {
250
+ panePromise = spawnTaskPane(entry.logPath, entry.description)
251
+ panePromise.then((pane) => {
252
+ if (pane) entry.tmuxPane = pane
253
+ })
254
+ }
172
255
  if (input.timeoutMs) {
173
256
  entry.timeoutTimer = setTimeout(() => kill(id), input.timeoutMs)
174
257
  }
@@ -181,13 +264,24 @@ export function createTaskManager(opts: {
181
264
  opts.onStall,
182
265
  )
183
266
  }
184
- proc.exited.then((code) => {
267
+ proc.exited.then(async (code) => {
185
268
  if (entry.timeoutTimer) clearTimeout(entry.timeoutTimer)
186
269
  if (entry.stallTimer) clearInterval(entry.stallTimer)
270
+ if (panePromise) {
271
+ const pane = await panePromise
272
+ pane?.close()
273
+ } else if (entry.tmuxPane) {
274
+ void entry.tmuxPane.close()
275
+ }
187
276
  if (entry.status === "running") entry.status = "exited"
188
277
  entry.exitCode = code
189
278
  persistMirror()
190
- opts.onExit?.(strip(entry))
279
+ try {
280
+ await opts.onExit?.(strip(entry))
281
+ } finally {
282
+ pruneFinishedTasks()
283
+ persistMirror()
284
+ }
191
285
  })
192
286
  return strip(entry)
193
287
  }
@@ -196,12 +290,16 @@ export function createTaskManager(opts: {
196
290
  const entry = tasks.get(id)
197
291
  if (!entry || entry.status !== "running") return false
198
292
  entry.status = "killed"
293
+ pruneFinishedTasks()
199
294
  persistMirror()
200
295
  if (entry.stallTimer) clearInterval(entry.stallTimer)
201
296
  entry.stallTimer = undefined
202
- entry.proc.kill("SIGTERM")
203
- const hard = setTimeout(() => entry.proc.kill("SIGKILL"), 3000)
204
- entry.proc.exited.then(() => clearTimeout(hard))
297
+ if (entry.tmuxPane) void entry.tmuxPane.close()
298
+ if (entry.proc) {
299
+ void killProcessTree(entry.proc, "SIGTERM")
300
+ const hard = setTimeout(() => void killProcessTree(entry.proc, "SIGKILL"), 3000)
301
+ entry.proc.exited.then(() => clearTimeout(hard))
302
+ }
205
303
  return true
206
304
  }
207
305
 
@@ -216,10 +314,7 @@ export function createTaskManager(opts: {
216
314
  output: async (id, tailLines = 50) => {
217
315
  const e = tasks.get(id)
218
316
  if (!e) return `no task ${id}`
219
- const file = Bun.file(e.logPath)
220
- if (!(await file.exists())) return "(no output)"
221
- const lines = (await file.text()).split("\n")
222
- return lines.slice(-tailLines - 1).join("\n")
317
+ return readLogTail(e.logPath, tailLines)
223
318
  },
224
319
  killAll: () => {
225
320
  for (const id of tasks.keys()) kill(id)
@@ -237,17 +332,10 @@ const fmt = (t: TaskRecord) =>
237
332
  export const tasks: FeatureModule = {
238
333
  name: "tasks",
239
334
  tools: ["task_run", "task_status", "task_output", "task_kill"],
240
- options: {
241
- killOnExit: "boolean",
242
- stallDetection: "boolean",
243
- stallThresholdMs: "number",
244
- stallCheckIntervalMs: "number",
245
- },
246
335
  defaultEnabled: true,
247
336
  requires: ["session.promptAsync", "session.messages"],
248
337
  async init(ctx, options, shared) {
249
338
  const logDir = await ensureStateDir(ctx.directory, "tasks")
250
- const stateDir = await ensureStateDir(ctx.directory)
251
339
  const killOnExit = options.killOnExit !== false
252
340
  const stallDetection = options.stallDetection !== false
253
341
  const stallThresholdMs =
@@ -257,10 +345,15 @@ export const tasks: FeatureModule = {
257
345
 
258
346
  const manager = createTaskManager({
259
347
  logDir,
260
- mirrorPath: `${stateDir}/tasks.json`,
348
+ mirrorPath: taskStore.path(ctx.directory),
349
+ tmux: options.tmux === true,
350
+ sanitizeEnv: options.sanitizeEnv !== false,
351
+ envAllowlist: Array.isArray(options.envAllowlist) ? (options.envAllowlist as string[]) : undefined,
352
+ maxTasks: typeof options.maxTasks === "number" ? options.maxTasks : 100,
261
353
  onExit: async (task) => {
262
354
  if (task.status === "killed") return
263
- const tail = await manager.output(task.id, 20)
355
+ const rawTail = await readLogTail(task.logPath, 20)
356
+ const tail = redactSensitiveOutput(rawTail)
264
357
  const ok = task.exitCode === 0
265
358
  await toast(
266
359
  ctx.client,
@@ -278,12 +371,13 @@ export const tasks: FeatureModule = {
278
371
  stallThresholdMs,
279
372
  stallCheckIntervalMs,
280
373
  onStall: async (task: TaskRecord, tail: string) => {
374
+ const safeTail = redactSensitiveOutput(tail.trimEnd())
281
375
  await toast(ctx.client, `task ${task.id} looks stalled (waiting for input?)`, "warning")
282
376
  await inject(
283
377
  ctx.client,
284
378
  task.sessionID,
285
379
  `[background task ${task.id} "${task.description}" appears to be waiting for interactive input]\n` +
286
- `last output:\n${tail.trimEnd()}\n\n` +
380
+ `last output:\n${safeTail}\n\n` +
287
381
  `The command is likely blocked on a prompt. Kill it with ${shared.toolName("task_kill")} and re-run non-interactively ` +
288
382
  `(e.g. pipe input like \`echo y | cmd\`, or pass a --yes/--force flag).`,
289
383
  )
@@ -293,6 +387,9 @@ export const tasks: FeatureModule = {
293
387
  })
294
388
 
295
389
  return {
390
+ "shell.env": async (_input, output) => {
391
+ Object.assign(output.env, NON_INTERACTIVE_ENV)
392
+ },
296
393
  dispose: async () => {
297
394
  if (killOnExit) manager.killAll()
298
395
  },
@@ -307,6 +404,10 @@ export const tasks: FeatureModule = {
307
404
  timeout: z.number().optional().describe("seconds until auto-kill"),
308
405
  },
309
406
  async execute(args, tctx) {
407
+ const blocked = detectInteractiveCommand(args.command)
408
+ if (blocked) {
409
+ return `Error: Command '${args.command}' appears to require interactive input (${blocked}). Background tasks run non-interactively and will hang on prompts.`
410
+ }
310
411
  const task = manager.run({
311
412
  command: args.command,
312
413
  description: args.description,
@@ -0,0 +1,116 @@
1
+ import type { FeatureModule } from "../types.ts"
2
+
3
+ export const DEFAULT_TRUNCATABLE_TOOLS = ["task_output", "bash", "grep", "glob", "webfetch"]
4
+
5
+ export const DEFAULT_MAX_CHARS = 40_000
6
+ export const DEFAULT_HEAD_LINES = 10
7
+ export const DEFAULT_TAIL_LINES = 30
8
+
9
+ export interface TruncateResult {
10
+ text: string
11
+ truncated: boolean
12
+ omittedLines: number
13
+ omittedChars: number
14
+ }
15
+
16
+ /**
17
+ * Smartly truncate output preserving top context and bottom tail.
18
+ * Enforces strict character and line bounds so oversized lines never overflow context.
19
+ */
20
+ export function truncateOutput(
21
+ content: string,
22
+ maxChars = DEFAULT_MAX_CHARS,
23
+ headLinesCount = DEFAULT_HEAD_LINES,
24
+ tailLinesCount = DEFAULT_TAIL_LINES,
25
+ ): TruncateResult {
26
+ const effectiveMax = Math.max(100, maxChars)
27
+ if (content.length <= effectiveMax) {
28
+ return { text: content, truncated: false, omittedLines: 0, omittedChars: 0 }
29
+ }
30
+
31
+ const headCount = Math.max(0, headLinesCount)
32
+ const tailCount = Math.max(0, tailLinesCount)
33
+ const lines = content.split("\n")
34
+
35
+ const maxHeadChars = Math.floor(effectiveMax * 0.3)
36
+ const maxTailChars = Math.floor(effectiveMax * 0.7)
37
+
38
+ if (lines.length <= headCount + tailCount) {
39
+ // If few lines but very long strings, hard slice
40
+ const head = content.slice(0, maxHeadChars)
41
+ const tail = maxTailChars > 0 ? content.slice(-maxTailChars) : ""
42
+ const omittedChars = Math.max(0, content.length - head.length - tail.length)
43
+ return {
44
+ text: `${head}\n\n[... truncated ${omittedChars} characters to stay within context limits ...]\n\n${tail}`,
45
+ truncated: true,
46
+ omittedLines: 0,
47
+ omittedChars,
48
+ }
49
+ }
50
+
51
+ let head = headCount > 0 ? lines.slice(0, headCount).join("\n") : ""
52
+ let tail = tailCount > 0 ? lines.slice(-tailCount).join("\n") : ""
53
+
54
+ // Guard against giant single lines in head or tail violating maxChars limit
55
+ if (head.length > maxHeadChars) {
56
+ head = head.slice(0, maxHeadChars) + "\n... [line truncated]"
57
+ }
58
+ if (tail.length > maxTailChars) {
59
+ tail = "[line truncated] ...\n" + (maxTailChars > 0 ? tail.slice(-maxTailChars) : "")
60
+ }
61
+
62
+ const omittedLines = Math.max(0, lines.length - headCount - tailCount)
63
+ const omittedChars = Math.max(0, content.length - head.length - tail.length)
64
+
65
+ const text = `${head}\n\n[... truncated ${omittedLines} lines (${omittedChars} chars) to stay within context limits ...]\n\n${tail}`
66
+ return {
67
+ text,
68
+ truncated: true,
69
+ omittedLines,
70
+ omittedChars,
71
+ }
72
+ }
73
+
74
+ export interface TruncatorOptions {
75
+ maxChars?: number
76
+ tools?: string[]
77
+ headLines?: number
78
+ tailLines?: number
79
+ }
80
+
81
+ /**
82
+ * Truncator feature module: guards against sudden context window exhaustion
83
+ * by trimming high-volume tool outputs while keeping diagnostic head and tail lines.
84
+ */
85
+ export const truncator: FeatureModule = {
86
+ name: "truncator",
87
+ tools: [],
88
+ defaultEnabled: true,
89
+ async init(_ctx, options, shared) {
90
+ const maxChars = typeof options.maxChars === "number" ? options.maxChars : DEFAULT_MAX_CHARS
91
+ const rawTools = Array.isArray(options.tools)
92
+ ? (options.tools as string[])
93
+ : DEFAULT_TRUNCATABLE_TOOLS
94
+ const targetTools = new Set(
95
+ rawTools.flatMap((t) => {
96
+ const lower = t.toLowerCase()
97
+ const remapped = shared?.toolName ? shared.toolName(t).toLowerCase() : lower
98
+ return [lower, remapped]
99
+ }),
100
+ )
101
+ const headLines = typeof options.headLines === "number" ? options.headLines : DEFAULT_HEAD_LINES
102
+ const tailLines = typeof options.tailLines === "number" ? options.tailLines : DEFAULT_TAIL_LINES
103
+
104
+ return {
105
+ "tool.execute.after": async (input, output) => {
106
+ if (!targetTools.has(input.tool.toLowerCase())) return
107
+ if (typeof output.output !== "string") return
108
+
109
+ const res = truncateOutput(output.output, maxChars, headLines, tailLines)
110
+ if (res.truncated) {
111
+ output.output = res.text
112
+ }
113
+ },
114
+ }
115
+ },
116
+ }
@@ -1,27 +1,9 @@
1
- import { tool } from "@opencode-ai/plugin"
2
1
  import type { FeatureModule } from "../types.ts"
3
2
  import { ensureStateDir, readJson, writeJson } from "../lib/state.ts"
3
+ import { usageStore, type DayBucket, type UsageState, type UsageTokens } from "../lib/mirror.ts"
4
4
 
5
- const z = tool.schema
6
-
7
- export interface UsageTokens {
8
- input: number
9
- output: number
10
- reasoning: number
11
- cacheRead: number
12
- cacheWrite: number
13
- }
14
-
15
- export interface DayBucket {
16
- cost: number
17
- tokens: UsageTokens
18
- messages: number
19
- seen: string[]
20
- }
21
-
22
- export interface UsageState {
23
- days: Record<string, DayBucket>
24
- }
5
+ // Declared in platform/storage/store.ts so the TUI can inspect usage data without importing this feature module.
6
+ export type { UsageTokens, DayBucket, UsageState } from "../lib/mirror.ts"
25
7
 
26
8
  export interface SessionUsage {
27
9
  sessionID: string
@@ -30,6 +12,8 @@ export interface SessionUsage {
30
12
  }
31
13
 
32
14
  const RETENTION_DAYS = 60
15
+ const MAX_SEEN_PER_DAY = 1000
16
+ const MAX_TRACKED_SESSIONS = 200
33
17
 
34
18
  function zeroTokens(): UsageTokens {
35
19
  return { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0 }
@@ -91,6 +75,7 @@ export function createUsageTracker(opts: UsageTrackerOpts): UsageTracker {
91
75
  }
92
76
 
93
77
  async function flush(): Promise<void> {
78
+ pruneOldDays(state, Date.now())
94
79
  await writeJson(opts.statePath, state)
95
80
  }
96
81
 
@@ -99,49 +84,51 @@ export function createUsageTracker(opts: UsageTrackerOpts): UsageTracker {
99
84
  pruneOldDays(state, Date.now())
100
85
  }
101
86
 
87
+ function extractTokens(t: any): UsageTokens {
88
+ return {
89
+ input: t?.input ?? 0,
90
+ output: t?.output ?? 0,
91
+ reasoning: t?.reasoning ?? 0,
92
+ cacheRead: t?.cache?.read ?? 0,
93
+ cacheWrite: t?.cache?.write ?? 0,
94
+ }
95
+ }
96
+
102
97
  function onEvent(event: { type: string; properties?: unknown }): void {
103
98
  try {
99
+ if (event.type === "session.deleted") {
100
+ const props = event.properties as { sessionID?: string; info?: { id?: string } } | undefined
101
+ const id = props?.sessionID ?? props?.info?.id
102
+ if (id) sessions.delete(id)
103
+ return
104
+ }
105
+
104
106
  if (event.type !== "message.updated") return
105
- const info = (event.properties as { info?: unknown } | undefined)?.info as
106
- | {
107
- id?: string
108
- role?: string
109
- sessionID?: string
110
- time?: { created?: number; completed?: number }
111
- cost?: number
112
- tokens?: {
113
- input?: number
114
- output?: number
115
- reasoning?: number
116
- cache?: { read?: number; write?: number }
117
- }
118
- }
119
- | undefined
120
- if (!info || info.role !== "assistant") return
121
- if (!info.time?.completed) return
107
+ const info = (event.properties as any)?.info
108
+ if (!info || info.role !== "assistant" || !info.time?.completed) return
122
109
  if (typeof info.id !== "string" || typeof info.sessionID !== "string") return
110
+
123
111
  const created = info.time.created ?? info.time.completed
124
112
  const day = dayKey(created)
125
113
  const bucket = state.days[day] ?? emptyBucket()
126
114
  state.days[day] = bucket
127
115
  if (bucket.seen.includes(info.id)) return
116
+
117
+ if (bucket.seen.length >= MAX_SEEN_PER_DAY) {
118
+ bucket.seen = bucket.seen.slice(-MAX_SEEN_PER_DAY + 1)
119
+ }
128
120
  bucket.seen.push(info.id)
129
121
 
130
122
  const cost = info.cost ?? 0
131
- const t = info.tokens ?? {}
132
- const input = t.input ?? 0
133
- const output = t.output ?? 0
134
- const reasoning = t.reasoning ?? 0
135
- const cacheRead = t.cache?.read ?? 0
136
- const cacheWrite = t.cache?.write ?? 0
123
+ const tok = extractTokens(info.tokens)
137
124
 
138
125
  bucket.cost += cost
139
126
  bucket.messages += 1
140
- bucket.tokens.input += input
141
- bucket.tokens.output += output
142
- bucket.tokens.reasoning += reasoning
143
- bucket.tokens.cacheRead += cacheRead
144
- bucket.tokens.cacheWrite += cacheWrite
127
+ bucket.tokens.input += tok.input
128
+ bucket.tokens.output += tok.output
129
+ bucket.tokens.reasoning += tok.reasoning
130
+ bucket.tokens.cacheRead += tok.cacheRead
131
+ bucket.tokens.cacheWrite += tok.cacheWrite
145
132
 
146
133
  const sess = sessions.get(info.sessionID) ?? {
147
134
  sessionID: info.sessionID,
@@ -149,10 +136,16 @@ export function createUsageTracker(opts: UsageTrackerOpts): UsageTracker {
149
136
  tokens: { input: 0, output: 0 },
150
137
  }
151
138
  sess.cost += cost
152
- sess.tokens.input += input
153
- sess.tokens.output += output
139
+ sess.tokens.input += tok.input
140
+ sess.tokens.output += tok.output
154
141
  sessions.set(info.sessionID, sess)
155
142
 
143
+ if (sessions.size > MAX_TRACKED_SESSIONS) {
144
+ // Evict oldest session to bound memory
145
+ const oldest = sessions.keys().next().value
146
+ if (oldest) sessions.delete(oldest)
147
+ }
148
+
156
149
  scheduleFlush()
157
150
  } catch (e) {
158
151
  console.warn(`[overclock] usage: event handling failed: ${e}`)
@@ -236,11 +229,11 @@ export function createUsageTracker(opts: UsageTrackerOpts): UsageTracker {
236
229
  */
237
230
  export const usage: FeatureModule = {
238
231
  name: "usage",
239
- tools: ["usage_report"],
232
+ tools: [],
240
233
  defaultEnabled: true,
241
234
  async init(ctx) {
242
235
  const dir = await ensureStateDir(ctx.directory)
243
- const tracker = createUsageTracker({ statePath: `${dir}/usage.json` })
236
+ const tracker = createUsageTracker({ statePath: usageStore.path(ctx.directory) })
244
237
  await tracker.load()
245
238
 
246
239
  return {
@@ -254,18 +247,6 @@ export const usage: FeatureModule = {
254
247
  console.warn(`[overclock] usage: event handling failed: ${e}`)
255
248
  }
256
249
  },
257
- tool: {
258
- usage_report: tool({
259
- description:
260
- "Report cost/token usage for the last N days (default 7) plus today's per-session breakdown.",
261
- args: {
262
- days: z.number().optional().describe("number of days to report, default 7"),
263
- },
264
- async execute(args) {
265
- return tracker.report(args.days ?? 7)
266
- },
267
- }),
268
- },
269
250
  }
270
251
  },
271
252
  }