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
@@ -0,0 +1,164 @@
1
+ import type { Hooks, PluginInput } from "@opencode-ai/plugin"
2
+ import type { PluginContext, Plugin as V2Plugin, PluginOptions } from "@opencode-ai/plugin/v2/promise"
3
+
4
+ /**
5
+ * Tracks live session busy/idle state derived from the host event bus.
6
+ */
7
+ export interface BusyTracker {
8
+ onEvent(event: unknown): void
9
+ isBusy(sessionID?: string): boolean
10
+ }
11
+
12
+ /**
13
+ * Singletons built once by the entry and handed to every module.
14
+ * `busy` is derived from the event bus and requires exactly one subscription -- a per-module
15
+ * copy would be N subscriptions maintaining N identical copies of the same state.
16
+ */
17
+ export interface SharedDeps {
18
+ /** live per-session busy/idle state; the entry owns the subscription that feeds it */
19
+ busy: BusyTracker
20
+ /**
21
+ * Declared tool name -> the name the model was actually offered (see `toolNames` config).
22
+ * Needed wherever a module names one of its own tools in text the model reads: under a
23
+ * remap the declared name is not a tool the model has.
24
+ */
25
+ toolName(declared: string): string
26
+ }
27
+
28
+ /** A problem found in plugin configuration. */
29
+ export interface ConfigIssue {
30
+ path: string
31
+ message: string
32
+ }
33
+
34
+ /** Policy for renaming tools and withholding tools not permitted by allowlists. */
35
+ export interface ToolPolicy {
36
+ /** declared name -> model-visible name */
37
+ readonly rename: Record<string, string>
38
+ /** declared names withheld from the model entirely (not in allowlist) */
39
+ readonly withheld: Set<string>
40
+ }
41
+
42
+ export const EMPTY_POLICY: ToolPolicy = { rename: {}, withheld: new Set() }
43
+
44
+ export interface TasksOptions {
45
+ killOnExit?: boolean
46
+ stallDetection?: boolean
47
+ stallThresholdMs?: number
48
+ stallCheckIntervalMs?: number
49
+ tmux?: boolean
50
+ [key: string]: unknown
51
+ }
52
+
53
+ export interface SchedOptions {
54
+ skipIfBusy?: boolean
55
+ [key: string]: unknown
56
+ }
57
+
58
+ export interface GuardHookConfig {
59
+ name: string
60
+ tools?: string[]
61
+ pathFilter?: string
62
+ run: string
63
+ mode?: "inject" | "append"
64
+ debounceMs?: number
65
+ timeoutMs?: number
66
+ onSuccess?: "silent" | "notify"
67
+ maxDeferMs?: number
68
+ [key: string]: unknown
69
+ }
70
+
71
+ export interface GuardOptions {
72
+ hooks?: GuardHookConfig[]
73
+ recipes?: string[]
74
+ auto?: boolean
75
+ editRecovery?: boolean
76
+ [key: string]: unknown
77
+ }
78
+
79
+ export interface RecoveryOptions {
80
+ autoResume?: boolean
81
+ maxAttempts?: number
82
+ cooldownMs?: number
83
+ [key: string]: unknown
84
+ }
85
+
86
+ export interface TruncatorOptions {
87
+ tools?: string[]
88
+ headLines?: number
89
+ tailLines?: number
90
+ maxChars?: number
91
+ [key: string]: unknown
92
+ }
93
+
94
+ /**
95
+ * Accepted formats for declaring V2 plugins:
96
+ * file path, npm package, tuple with options, or plugin object.
97
+ */
98
+ export type V2PluginSpec =
99
+ string | [string, PluginOptions] | V2Plugin | { plugin: V2Plugin; options?: PluginOptions }
100
+
101
+ /**
102
+ * Plugin options passed directly from opencode.json:
103
+ * { "plugin": [ ["opencode-overclock", { "guard": { ... } }] ] }
104
+ */
105
+ export interface OverclockOptions {
106
+ tasks?: boolean | TasksOptions
107
+ sched?: boolean | SchedOptions
108
+ guard?: boolean | GuardOptions
109
+ usage?: boolean | { debounceMs?: number }
110
+ buddy?: boolean
111
+ recovery?: boolean | RecoveryOptions
112
+ truncator?: boolean | TruncatorOptions
113
+ /** Legacy or grouped feature options: { features: { guard: ... } } */
114
+ features?: Record<string, boolean | Record<string, unknown>>
115
+ /** Model-visible tool ids: declared name -> replacement */
116
+ toolNames?: Record<string, string>
117
+ /** Whitelist of tool names the model may be offered */
118
+ toolAllowlist?: string[] | string
119
+ /**
120
+ * V2 plugins to host and run alongside V1:
121
+ * file paths (e.g. "./plugins/custom.ts"), npm specifiers, plugin objects, or [spec, options] tuples.
122
+ */
123
+ plugins?: V2PluginSpec[]
124
+ [key: string]: unknown
125
+ }
126
+
127
+ /**
128
+ * One feature = one module.
129
+ * Can participate in V1 hook composition (`init`), V2 domain transforms (`setup`), or both.
130
+ */
131
+ export interface FeatureModule {
132
+ name: string
133
+ /** on by default? */
134
+ defaultEnabled: boolean
135
+ /** SDK client surfaces (dot-paths) the module needs in V1. Missing -> module skipped + warn. */
136
+ requires?: string[]
137
+ /** Tool names registered. Declared, not derived -- feeds the first-run summary. */
138
+ tools?: string[]
139
+ /** V1 initialization: returns partial Hooks composed in registry order. */
140
+ init(ctx: PluginInput, options: Record<string, unknown>, shared: SharedDeps): Promise<Partial<Hooks>>
141
+ /** V2 setup: receives V2 PluginContext to register domain transforms. */
142
+ setup?(context: PluginContext, options: Record<string, unknown>): Promise<void> | void
143
+ }
144
+
145
+ /**
146
+ * Definition structure for a dual-target plugin (V1 hooks + V2 setup).
147
+ */
148
+ export interface HybridPluginDefinition<TOptions = Record<string, unknown>> {
149
+ readonly id: string
150
+ /** V1 lifecycle: tools, execution interception, event bus hooks */
151
+ readonly server?: (input: PluginInput, options?: TOptions) => Promise<Partial<Hooks>>
152
+ /** V2 lifecycle: domain transforms (agents, commands, catalog, aisdk) */
153
+ readonly setup?: (context: PluginContext, options?: TOptions) => Promise<void> | void
154
+ }
155
+
156
+ /**
157
+ * Plugin instance callable directly in V1 while presenting V2 interface object.
158
+ */
159
+ export interface HybridPlugin<TOptions = Record<string, unknown>> extends V2Plugin {
160
+ (input: PluginInput, options?: TOptions): Promise<Hooks>
161
+ readonly id: string
162
+ readonly server: (input: PluginInput, options?: TOptions) => Promise<Hooks>
163
+ readonly setup: (context: PluginContext) => Promise<void>
164
+ }
@@ -3,8 +3,7 @@ import type { FeatureModule } from "../types.ts"
3
3
  /**
4
4
  * Buddy is a TUI-surface feature (src/buddy/, wired in src/tui.ts): an ASCII pet
5
5
  * beside the prompt. This server module registers no hooks or tools -- it exists
6
- * so `features.buddy` validates in overclock.json (one config file toggles both
7
- * surfaces) and the first-run summary mentions it.
6
+ * so buddy appears in the first-run capability summary.
8
7
  */
9
8
  export const buddy: FeatureModule = {
10
9
  name: "buddy",
@@ -1,10 +1,12 @@
1
1
  import type { FeatureModule } from "../types.ts"
2
2
  import { inject, toast } from "../lib/inject.ts"
3
+ import { execBash } from "../lib/exec.ts"
3
4
 
4
5
  export interface GuardHook {
5
6
  name: string
6
7
  tools: string[]
7
8
  pathFilter?: string
9
+ glob?: Bun.Glob
8
10
  run: string
9
11
  mode: "inject" | "append"
10
12
  debounceMs: number
@@ -14,6 +16,102 @@ export interface GuardHook {
14
16
  maxDeferMs: number
15
17
  }
16
18
 
19
+ export const EDIT_ERROR_PATTERNS = [
20
+ "oldstring and newstring must be different",
21
+ "oldstring not found",
22
+ "found multiple matches for oldstring",
23
+ ]
24
+
25
+ export const EDIT_RECOVERY_HINT =
26
+ "\n\n[edit recovery hint]\nThe edit failed due to a content mismatch. Use the `read` tool to inspect the latest file state around the target lines before retrying the edit."
27
+
28
+ export function checkEditFailure(tool: string, outputText: string): string | null {
29
+ if (tool.toLowerCase() !== "edit") return null
30
+ const lower = outputText.toLowerCase()
31
+ if (EDIT_ERROR_PATTERNS.some((p) => lower.includes(p))) {
32
+ return EDIT_RECOVERY_HINT
33
+ }
34
+ return null
35
+ }
36
+
37
+ export const GUARD_RECIPES: Record<string, Omit<GuardHook, "glob">> = {
38
+ tsc: {
39
+ name: "tsc",
40
+ tools: ["edit", "write"],
41
+ pathFilter: "**/*.{ts,tsx}",
42
+ run: "bun x tsc --noEmit || npx tsc --noEmit",
43
+ mode: "inject",
44
+ debounceMs: 2000,
45
+ timeoutMs: 60000,
46
+ onSuccess: "silent",
47
+ maxDeferMs: 300000,
48
+ },
49
+ eslint: {
50
+ name: "eslint",
51
+ tools: ["edit", "write"],
52
+ pathFilter: "**/*.{js,jsx,ts,tsx}",
53
+ run: "bun x eslint . || npx eslint .",
54
+ mode: "inject",
55
+ debounceMs: 2000,
56
+ timeoutMs: 60000,
57
+ onSuccess: "silent",
58
+ maxDeferMs: 300000,
59
+ },
60
+ ruff: {
61
+ name: "ruff",
62
+ tools: ["edit", "write"],
63
+ pathFilter: "**/*.py",
64
+ run: "ruff check .",
65
+ mode: "inject",
66
+ debounceMs: 2000,
67
+ timeoutMs: 60000,
68
+ onSuccess: "silent",
69
+ maxDeferMs: 300000,
70
+ },
71
+ cargo: {
72
+ name: "cargo",
73
+ tools: ["edit", "write"],
74
+ pathFilter: "**/*.rs",
75
+ run: "cargo check",
76
+ mode: "inject",
77
+ debounceMs: 2000,
78
+ timeoutMs: 60000,
79
+ onSuccess: "silent",
80
+ maxDeferMs: 300000,
81
+ },
82
+ go: {
83
+ name: "go",
84
+ tools: ["edit", "write"],
85
+ pathFilter: "**/*.go",
86
+ run: "go test ./...",
87
+ mode: "inject",
88
+ debounceMs: 2000,
89
+ timeoutMs: 60000,
90
+ onSuccess: "silent",
91
+ maxDeferMs: 300000,
92
+ },
93
+ }
94
+
95
+ export async function detectRecipes(directory: string): Promise<GuardHook[]> {
96
+ const detected: GuardHook[] = []
97
+ if (await Bun.file(`${directory}/tsconfig.json`).exists()) {
98
+ detected.push({ ...GUARD_RECIPES.tsc, glob: new Bun.Glob("**/*.{ts,tsx}") })
99
+ }
100
+ if (await Bun.file(`${directory}/Cargo.toml`).exists()) {
101
+ detected.push({ ...GUARD_RECIPES.cargo, glob: new Bun.Glob("**/*.rs") })
102
+ }
103
+ if (
104
+ (await Bun.file(`${directory}/pyproject.toml`).exists()) ||
105
+ (await Bun.file(`${directory}/ruff.toml`).exists())
106
+ ) {
107
+ detected.push({ ...GUARD_RECIPES.ruff, glob: new Bun.Glob("**/*.py") })
108
+ }
109
+ if (await Bun.file(`${directory}/go.mod`).exists()) {
110
+ detected.push({ ...GUARD_RECIPES.go, glob: new Bun.Glob("**/*.go") })
111
+ }
112
+ return detected
113
+ }
114
+
17
115
  /** options.hooks -> validated GuardHook[]. Invalid entries -> console.warn, skipped, never throw. */
18
116
  export function parseHooks(raw: unknown): GuardHook[] {
19
117
  if (!Array.isArray(raw)) return []
@@ -26,10 +124,12 @@ export function parseHooks(raw: unknown): GuardHook[] {
26
124
  console.warn(`[overclock] guard: skipping invalid hook config: ${JSON.stringify(entry)}`)
27
125
  continue
28
126
  }
127
+ const pathFilter = typeof e.pathFilter === "string" ? e.pathFilter : undefined
29
128
  hooks.push({
30
129
  name: e.name,
31
130
  tools: e.tools as string[],
32
- pathFilter: typeof e.pathFilter === "string" ? e.pathFilter : undefined,
131
+ pathFilter,
132
+ glob: pathFilter ? new Bun.Glob(pathFilter) : undefined,
33
133
  run: e.run,
34
134
  mode: e.mode === "append" ? "append" : "inject",
35
135
  debounceMs: typeof e.debounceMs === "number" ? e.debounceMs : 2000,
@@ -41,12 +141,22 @@ export function parseHooks(raw: unknown): GuardHook[] {
41
141
  return hooks
42
142
  }
43
143
 
44
- /** tools: exact match. pathFilter set + no filePath -> no match. */
45
- export function matchHook(hook: GuardHook, toolName: string, filePath: string | undefined): boolean {
46
- if (!hook.tools.includes(toolName)) return false
144
+ /** tools: exact match or mapped name. pathFilter set + no filePath -> no match. */
145
+ export function matchHook(
146
+ hook: GuardHook,
147
+ toolName: string,
148
+ filePath: string | undefined,
149
+ resolveTool?: (name: string) => string,
150
+ ): boolean {
151
+ const matches = hook.tools.some((t) => {
152
+ if (t === toolName) return true
153
+ if (resolveTool && resolveTool(t) === toolName) return true
154
+ return false
155
+ })
156
+ if (!matches) return false
47
157
  if (!hook.pathFilter) return true
48
158
  if (typeof filePath !== "string") return false
49
- return new Bun.Glob(hook.pathFilter).match(filePath)
159
+ return (hook.glob ?? new Bun.Glob(hook.pathFilter)).match(filePath)
50
160
  }
51
161
 
52
162
  /** `[guard "<name>" failed (exit <code>)]` + last 40 lines of combined stdout+stderr. */
@@ -69,16 +179,7 @@ async function runCommand(
69
179
  env: Record<string, string | undefined>,
70
180
  register?: (proc: Bun.Subprocess) => void,
71
181
  ): Promise<{ code: number | null; combined: string }> {
72
- const proc = Bun.spawn(["bash", "-c", hook.run], { cwd, env, stdout: "pipe", stderr: "pipe" })
73
- register?.(proc)
74
- const killTimer = setTimeout(() => proc.kill("SIGTERM"), hook.timeoutMs)
75
- const [out, err, code] = await Promise.all([
76
- new Response(proc.stdout).text(),
77
- new Response(proc.stderr).text(),
78
- proc.exited,
79
- ])
80
- clearTimeout(killTimer)
81
- return { code, combined: out + err }
182
+ return execBash(hook.run, { cwd, env, timeoutMs: hook.timeoutMs, onSpawn: register })
82
183
  }
83
184
 
84
185
  interface HookState {
@@ -200,7 +301,17 @@ export function createGuardRunner(deps: GuardRunnerDeps): GuardRunner {
200
301
  function dispose(): void {
201
302
  for (const s of states.values()) {
202
303
  if (s.timer) clearTimeout(s.timer)
203
- for (const p of s.procs) p.kill("SIGTERM")
304
+ for (const p of s.procs) {
305
+ try {
306
+ p.kill("SIGTERM")
307
+ const hard = setTimeout(() => {
308
+ try {
309
+ p.kill("SIGKILL")
310
+ } catch {}
311
+ }, 1000)
312
+ hard.unref?.()
313
+ } catch {}
314
+ }
204
315
  }
205
316
  }
206
317
 
@@ -218,7 +329,6 @@ export function createGuardRunner(deps: GuardRunnerDeps): GuardRunner {
218
329
  export const guard: FeatureModule = {
219
330
  name: "guard",
220
331
  tools: [],
221
- options: { hooks: "array" },
222
332
  defaultEnabled: true,
223
333
  requires: ["session.promptAsync", "session.messages"],
224
334
  async init(ctx, options, shared) {
@@ -226,29 +336,57 @@ export const guard: FeatureModule = {
226
336
  console.warn(`[overclock] guard: options.hooks must be an array, got ${typeof options.hooks}`)
227
337
  }
228
338
  const hooks = parseHooks(options.hooks)
229
- if (hooks.length === 0) return {}
339
+ if (Array.isArray(options.recipes)) {
340
+ for (const r of options.recipes) {
341
+ if (typeof r === "string" && GUARD_RECIPES[r]) {
342
+ const recipe = GUARD_RECIPES[r]
343
+ hooks.push({
344
+ ...recipe,
345
+ glob: recipe.pathFilter ? new Bun.Glob(recipe.pathFilter) : undefined,
346
+ })
347
+ }
348
+ }
349
+ }
350
+ if (options.auto === true) {
351
+ const autoHooks = await detectRecipes(ctx.directory)
352
+ hooks.push(...autoHooks)
353
+ }
230
354
 
231
- const runner = createGuardRunner({
232
- cwd: ctx.directory,
233
- onInject: async (sessionID, payload) => {
234
- await inject(ctx.client, sessionID, payload)
235
- },
236
- onNotify: async (hookName) => {
237
- await toast(ctx.client, `guard "${hookName}" passed`, "success")
238
- },
239
- isBusy: (sessionID) => shared.busy.isBusy(sessionID),
240
- })
355
+ const editRecovery =
356
+ options.editRecovery === true || (hooks.length > 0 && options.editRecovery !== false)
357
+ if (hooks.length === 0 && !editRecovery) return {}
358
+
359
+ const runner =
360
+ hooks.length > 0
361
+ ? createGuardRunner({
362
+ cwd: ctx.directory,
363
+ onInject: async (sessionID, payload) => {
364
+ await inject(ctx.client, sessionID, payload)
365
+ },
366
+ onNotify: async (hookName) => {
367
+ await toast(ctx.client, `guard "${hookName}" passed`, "success")
368
+ },
369
+ isBusy: (sessionID) => shared.busy.isBusy(sessionID),
370
+ })
371
+ : undefined
241
372
 
242
373
  return {
243
374
  dispose: async () => {
244
- runner.dispose()
375
+ runner?.dispose()
245
376
  },
246
377
  "tool.execute.after": async (input, output) => {
378
+ if (editRecovery && typeof output.output === "string") {
379
+ const hint = checkEditFailure(input.tool, output.output)
380
+ if (hint) output.output += hint
381
+ }
382
+
383
+ if (!runner || hooks.length === 0) return
384
+
247
385
  const args = input.args as Record<string, unknown> | undefined
248
386
  const filePath = typeof args?.filePath === "string" ? args.filePath : undefined
249
387
 
250
388
  for (const hook of hooks) {
251
- if (!matchHook(hook, input.tool, filePath)) continue
389
+ if (!matchHook(hook, input.tool, filePath, shared?.toolName)) continue
252
390
  if (hook.mode === "append") {
253
391
  const payload = await runner.runAppend(hook, input.tool, filePath)
254
392
  if (payload && typeof output.output === "string") output.output += payload
@@ -1,11 +1,13 @@
1
1
  import type { FeatureModule } from "../types.ts"
2
2
  import { tasks } from "./tasks.ts"
3
3
  import { sched } from "./sched.ts"
4
- import { sandbox } from "./sandbox.ts"
5
4
  import { guard } from "./guard.ts"
6
5
  import { usage } from "./usage.ts"
7
- import { checkpoints } from "./checkpoints.ts"
8
6
  import { buddy } from "./buddy.ts"
7
+ import { truncator } from "./truncator.ts"
8
+ import { recovery } from "./recovery.ts"
9
9
 
10
- /** Registry, ordered. Order = hook composition order. */
11
- export const features: FeatureModule[] = [tasks, sched, sandbox, guard, usage, checkpoints, buddy]
10
+ /**
11
+ * Registry, ordered. Order = hook composition order.
12
+ */
13
+ export const features: FeatureModule[] = [tasks, sched, guard, usage, buddy, truncator, recovery]
@@ -0,0 +1,143 @@
1
+ import type { FeatureModule } from "../types.ts"
2
+ import { inject, toast } from "../lib/inject.ts"
3
+
4
+ export interface RecoveryOptions {
5
+ maxAttempts?: number
6
+ cooldownMs?: number
7
+ autoResume?: boolean
8
+ }
9
+
10
+ export interface ErrorClassification {
11
+ recoverable: boolean
12
+ reason?:
13
+ | "tool_result_missing"
14
+ | "thinking_order"
15
+ | "thinking_disabled"
16
+ | "context_limit"
17
+ | "rate_limit"
18
+ | "transient"
19
+ message?: string
20
+ }
21
+
22
+ const ERROR_PATTERNS: Array<{ regex: RegExp; reason: ErrorClassification["reason"] }> = [
23
+ {
24
+ regex: /tool_use.*(?:without|missing).*tool_result|tool_result.*missing/i,
25
+ reason: "tool_result_missing",
26
+ },
27
+ { regex: /thinking.*(?:must precede|preceded by|order)/i, reason: "thinking_order" },
28
+ { regex: /thinking.*(?:not allowed|disabled|unsupported)/i, reason: "thinking_disabled" },
29
+ {
30
+ regex: /prompt is too long|context.*(?:limit|window).*exceeded|maximum context length/i,
31
+ reason: "context_limit",
32
+ },
33
+ { regex: /rate[ _-]?limit|too many requests|overloaded/i, reason: "rate_limit" },
34
+ { regex: /socket hang up|ECONNRESET|ETIMEDOUT|network error/i, reason: "transient" },
35
+ ]
36
+
37
+ /** Classify error into known recoverable provider failure modes. */
38
+ export function classifyError(error: unknown): ErrorClassification {
39
+ if (!error) return { recoverable: false }
40
+
41
+ const errorString =
42
+ typeof error === "string"
43
+ ? error
44
+ : error instanceof Error
45
+ ? `${error.name}: ${error.message}`
46
+ : JSON.stringify(error)
47
+
48
+ for (const { regex, reason } of ERROR_PATTERNS) {
49
+ if (regex.test(errorString)) {
50
+ return { recoverable: true, reason, message: errorString }
51
+ }
52
+ }
53
+
54
+ return { recoverable: false, message: errorString }
55
+ }
56
+
57
+ export interface RecoveryTracker {
58
+ canAttempt(sessionID: string): boolean
59
+ recordAttempt(sessionID: string): void
60
+ reset(sessionID: string): void
61
+ }
62
+
63
+ /** Rate-limits recovery attempts per session. */
64
+ export function createRecoveryTracker(maxAttempts = 3, cooldownMs = 60_000): RecoveryTracker {
65
+ const attempts = new Map<string, number[]>()
66
+
67
+ return {
68
+ canAttempt(sessionID: string): boolean {
69
+ const now = Date.now()
70
+ const list = (attempts.get(sessionID) ?? []).filter((t) => now - t < cooldownMs)
71
+ attempts.set(sessionID, list)
72
+ return list.length < maxAttempts
73
+ },
74
+ recordAttempt(sessionID: string): void {
75
+ const now = Date.now()
76
+ const list = (attempts.get(sessionID) ?? []).filter((t) => now - t < cooldownMs)
77
+ list.push(now)
78
+ attempts.set(sessionID, list)
79
+ },
80
+ reset(sessionID: string): void {
81
+ attempts.delete(sessionID)
82
+ },
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Recovery module: automatically detects and recovers from transient provider errors,
88
+ * protocol sequencing glitches, and context window issues during autonomous sessions.
89
+ */
90
+ export const recovery: FeatureModule = {
91
+ name: "recovery",
92
+ tools: [],
93
+ defaultEnabled: true,
94
+ requires: ["session.promptAsync", "session.messages"],
95
+ async init(ctx, options) {
96
+ const maxAttempts = typeof options.maxAttempts === "number" ? options.maxAttempts : 3
97
+ const cooldownMs = typeof options.cooldownMs === "number" ? options.cooldownMs : 60_000
98
+ const autoResume = options.autoResume !== false
99
+ const tracker = createRecoveryTracker(maxAttempts, cooldownMs)
100
+
101
+ return {
102
+ event: async ({ event }) => {
103
+ if (event.type === "session.deleted") {
104
+ const props = event.properties as { info?: { id?: string } } | undefined
105
+ if (props?.info?.id) tracker.reset(props.info.id)
106
+ return
107
+ }
108
+
109
+ if (event.type !== "session.error") return
110
+
111
+ const props = event.properties as { sessionID?: string; error?: unknown } | undefined
112
+ const sessionID = props?.sessionID
113
+ if (!sessionID || !props?.error) return
114
+
115
+ const classified = classifyError(props.error)
116
+ if (!classified.recoverable) return
117
+
118
+ if (!tracker.canAttempt(sessionID)) {
119
+ console.warn(
120
+ `[overclock] recovery: exceeded max attempts (${maxAttempts}) for session ${sessionID}`,
121
+ )
122
+ await toast(ctx.client, `recovery failed: too many consecutive errors in session`, "error")
123
+ return
124
+ }
125
+
126
+ tracker.recordAttempt(sessionID)
127
+ console.warn(
128
+ `[overclock] recovery: session ${sessionID} error (${classified.reason}), attempting auto-resume`,
129
+ )
130
+ await toast(ctx.client, `recovering session from ${classified.reason}...`, "warning")
131
+
132
+ if (autoResume) {
133
+ const resumeText =
134
+ classified.reason === "context_limit"
135
+ ? "[session recovered: context limit reached; summarize recent progress and continue with minimal output]"
136
+ : `[session recovered from ${classified.reason} - please continue with your previous task]`
137
+
138
+ await inject(ctx.client, sessionID, resumeText)
139
+ }
140
+ },
141
+ }
142
+ },
143
+ }