opencode-overclock 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +206 -111
- package/package.json +2 -2
- package/src/bridge.ts +1 -0
- package/src/buddy/companion.ts +104 -5
- package/src/buddy/sprites.ts +4 -4
- package/src/buddy/tui.ts +175 -65
- package/src/core/bridge.ts +34 -0
- package/src/core/lifecycle.ts +53 -0
- package/src/core/policy.ts +128 -0
- package/src/core/summary.ts +33 -0
- package/src/core/types.ts +164 -0
- package/src/features/buddy.ts +1 -2
- package/src/features/guard.ts +168 -30
- package/src/features/index.ts +6 -4
- package/src/features/recovery.ts +143 -0
- package/src/features/sched.ts +147 -89
- package/src/features/tasks.ts +52 -18
- package/src/features/truncator.ts +99 -0
- package/src/features/usage.ts +26 -65
- package/src/index.ts +96 -67
- package/src/lib/busy.ts +1 -25
- package/src/lib/exec.ts +7 -0
- package/src/lib/inject.ts +10 -56
- package/src/lib/mirror.ts +13 -0
- package/src/lib/probe.ts +1 -15
- package/src/lib/state.ts +10 -39
- package/src/lib/tmux.ts +1 -0
- package/src/lib/ui.ts +208 -0
- package/src/merge.ts +2 -66
- package/src/platform/probe.ts +25 -0
- package/src/platform/process/exec.ts +76 -0
- package/src/platform/process/tmux.ts +60 -0
- package/src/platform/session/busy.ts +33 -0
- package/src/platform/session/inject.ts +82 -0
- package/src/platform/session/notify.ts +20 -0
- package/src/platform/storage/state.ts +77 -0
- package/src/platform/storage/store.ts +61 -0
- package/src/summary.ts +1 -0
- package/src/tools.ts +8 -244
- package/src/tui.ts +57 -186
- package/src/types.ts +1 -73
- package/src/v2/context.ts +470 -0
- package/src/v2/host.ts +117 -0
- package/src/v2/loader.ts +150 -0
- package/src/buddy/reactions.ts +0 -41
- package/src/buddy/types.ts +0 -30
- package/src/config.ts +0 -19
- package/src/features/checkpoints.ts +0 -128
- package/src/features/sandbox.ts +0 -104
- package/src/validate.ts +0 -197
|
@@ -0,0 +1,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
|
+
}
|
package/src/features/buddy.ts
CHANGED
|
@@ -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
|
|
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",
|
package/src/features/guard.ts
CHANGED
|
@@ -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
|
|
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(
|
|
46
|
-
|
|
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
|
-
|
|
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)
|
|
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 (
|
|
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
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
|
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
|
package/src/features/index.ts
CHANGED
|
@@ -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
|
-
/**
|
|
11
|
-
|
|
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
|
+
}
|