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.
- package/README.md +252 -111
- package/package.json +6 -4
- package/skills/codebase-design/DEEPENING.md +35 -0
- package/skills/codebase-design/DESIGN-IT-TWICE.md +34 -0
- package/skills/codebase-design/SKILL.md +93 -0
- package/skills/diagnosing-bugs/SKILL.md +123 -0
- package/skills/domain-modeling/ADR-FORMAT.md +55 -0
- package/skills/domain-modeling/CONTEXT-FORMAT.md +32 -0
- package/skills/domain-modeling/SKILL.md +102 -0
- package/skills/doubt/SKILL.md +80 -0
- package/skills/grilling/SKILL.md +96 -0
- package/skills/source-discipline/SKILL.md +78 -0
- package/skills/tdd/SKILL.md +87 -0
- package/skills/to-spec/SKILL.md +69 -0
- package/skills/to-spec/SPEC-TEMPLATE.md +50 -0
- package/skills/to-tickets/SKILL.md +74 -0
- package/skills/to-tickets/TICKET-TEMPLATE.md +41 -0
- 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 +67 -0
- package/src/core/policy.ts +128 -0
- package/src/core/summary.ts +33 -0
- package/src/core/types.ts +193 -0
- package/src/features/buddy.ts +1 -2
- package/src/features/guard.ts +421 -37
- package/src/features/index.ts +18 -4
- package/src/features/recovery.ts +153 -0
- package/src/features/safety.ts +147 -0
- package/src/features/sched.ts +183 -89
- package/src/features/tasks.ts +134 -33
- package/src/features/truncator.ts +116 -0
- package/src/features/usage.ts +46 -65
- package/src/features/workflow.ts +256 -0
- package/src/index.ts +96 -67
- package/src/lib/busy.ts +1 -25
- package/src/lib/exec.ts +13 -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 +317 -0
- package/src/platform/process/tmux.ts +60 -0
- package/src/platform/session/busy.ts +33 -0
- package/src/platform/session/inject.ts +89 -0
- package/src/platform/session/notify.ts +20 -0
- package/src/platform/storage/state.ts +99 -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 +120 -0
- package/src/v2/loader.ts +150 -0
- package/src/workflow/agents/codebase-researcher.ts +27 -0
- package/src/workflow/agents/design-explorer.ts +33 -0
- package/src/workflow/agents/doubt-reviewer.ts +26 -0
- package/src/workflow/agents/engineering-coach.ts +23 -0
- package/src/workflow/agents/performance-auditor.ts +29 -0
- package/src/workflow/agents/security-auditor.ts +23 -0
- package/src/workflow/agents/spec-reviewer.ts +15 -0
- package/src/workflow/agents/standards-reviewer.ts +24 -0
- package/src/workflow/agents/test-engineer.ts +28 -0
- package/src/workflow/catalog.ts +210 -0
- package/src/workflow/templates/build.ts +47 -0
- package/src/workflow/templates/define.ts +45 -0
- package/src/workflow/templates/diagnose.ts +58 -0
- package/src/workflow/templates/plan.ts +52 -0
- package/src/workflow/templates/ship.ts +64 -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,153 @@
|
|
|
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
|
+
if (list.length === 0) {
|
|
72
|
+
attempts.delete(sessionID)
|
|
73
|
+
} else {
|
|
74
|
+
attempts.set(sessionID, list)
|
|
75
|
+
}
|
|
76
|
+
return list.length < maxAttempts
|
|
77
|
+
},
|
|
78
|
+
recordAttempt(sessionID: string): void {
|
|
79
|
+
const now = Date.now()
|
|
80
|
+
const list = (attempts.get(sessionID) ?? []).filter((t) => now - t < cooldownMs)
|
|
81
|
+
list.push(now)
|
|
82
|
+
attempts.set(sessionID, list)
|
|
83
|
+
},
|
|
84
|
+
reset(sessionID: string): void {
|
|
85
|
+
attempts.delete(sessionID)
|
|
86
|
+
},
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Recovery module: automatically detects and recovers from transient provider errors,
|
|
92
|
+
* protocol sequencing glitches, and context window issues during autonomous sessions.
|
|
93
|
+
*/
|
|
94
|
+
export const recovery: FeatureModule = {
|
|
95
|
+
name: "recovery",
|
|
96
|
+
tools: [],
|
|
97
|
+
defaultEnabled: true,
|
|
98
|
+
requires: ["session.promptAsync", "session.messages"],
|
|
99
|
+
async init(ctx, options) {
|
|
100
|
+
const maxAttempts = typeof options.maxAttempts === "number" ? options.maxAttempts : 3
|
|
101
|
+
const cooldownMs = typeof options.cooldownMs === "number" ? options.cooldownMs : 60_000
|
|
102
|
+
const autoResume = options.autoResume !== false
|
|
103
|
+
const tracker = createRecoveryTracker(maxAttempts, cooldownMs)
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
event: async ({ event }) => {
|
|
107
|
+
if (event.type === "session.deleted") {
|
|
108
|
+
const props = event.properties as { sessionID?: string; info?: { id?: string } } | undefined
|
|
109
|
+
const id = props?.sessionID ?? props?.info?.id
|
|
110
|
+
if (id) tracker.reset(id)
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (event.type !== "session.error") return
|
|
115
|
+
|
|
116
|
+
const props = event.properties as { sessionID?: string; error?: unknown } | undefined
|
|
117
|
+
const sessionID = props?.sessionID
|
|
118
|
+
if (!sessionID || !props?.error) return
|
|
119
|
+
|
|
120
|
+
const classified = classifyError(props.error)
|
|
121
|
+
if (!classified.recoverable) return
|
|
122
|
+
|
|
123
|
+
if (!tracker.canAttempt(sessionID)) {
|
|
124
|
+
console.warn(
|
|
125
|
+
`[overclock] recovery: exceeded max attempts (${maxAttempts}) for session ${sessionID}`,
|
|
126
|
+
)
|
|
127
|
+
await toast(ctx.client, `recovery failed: too many consecutive errors in session`, "error")
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
tracker.recordAttempt(sessionID)
|
|
132
|
+
console.warn(
|
|
133
|
+
`[overclock] recovery: session ${sessionID} error (${classified.reason}), attempting auto-resume`,
|
|
134
|
+
)
|
|
135
|
+
await toast(ctx.client, `recovering session from ${classified.reason}...`, "warning")
|
|
136
|
+
|
|
137
|
+
if (autoResume) {
|
|
138
|
+
if (classified.reason === "rate_limit") {
|
|
139
|
+
// Apply a brief backoff delay before retrying a throttled endpoint
|
|
140
|
+
await new Promise((r) => setTimeout(r, 2000))
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const resumeText =
|
|
144
|
+
classified.reason === "context_limit"
|
|
145
|
+
? "[session recovered: context limit reached; summarize recent progress and continue with minimal output]"
|
|
146
|
+
: `[session recovered from ${classified.reason} - please continue with your previous task]`
|
|
147
|
+
|
|
148
|
+
await inject(ctx.client, sessionID, resumeText)
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import type { FeatureModule } from "../types.ts"
|
|
2
|
+
import { toast } from "../lib/inject.ts"
|
|
3
|
+
import { shellQuote } from "../lib/exec.ts"
|
|
4
|
+
|
|
5
|
+
export interface DangerousPattern {
|
|
6
|
+
name: string
|
|
7
|
+
pattern: RegExp
|
|
8
|
+
reason: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const GIT_PREFIX =
|
|
12
|
+
"\\bgit(?:\\s+(?:-[a-zA-Z0-9_.-]+|--[a-zA-Z0-9_.-]+(?:=\\S+)?|-[a-zA-Z]\\s+(?:\"[^\"]*\"|'[^']*'|\\S+)|--(?:git-dir|work-tree|namespace)\\s+(?:\"[^\"]*\"|'[^']*'|\\S+)))*\\s+"
|
|
13
|
+
|
|
14
|
+
export const DANGEROUS_GIT_PATTERNS: DangerousPattern[] = [
|
|
15
|
+
{
|
|
16
|
+
name: "force-push",
|
|
17
|
+
pattern: new RegExp(
|
|
18
|
+
`${GIT_PREFIX}push\\b[^;&|\\n]*(?:\\s(?:--force(?:-with-lease)?\\b|-f\\b)|\\s\\+[^\\s:]+(?::\\S+)?)`,
|
|
19
|
+
),
|
|
20
|
+
reason: "Force-pushing can overwrite remote history.",
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
name: "hard-reset",
|
|
24
|
+
pattern: new RegExp(`${GIT_PREFIX}reset\\b[^;&|\\n]*(?:\\s|^)--hard\\b`),
|
|
25
|
+
reason: "Hard-reset discards uncommitted changes permanently.",
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
name: "force-clean",
|
|
29
|
+
pattern: new RegExp(`${GIT_PREFIX}clean\\b[^;&|\\n]*(?:\\s-[a-zA-Z]*f[a-zA-Z]*|\\s--force\\b)`),
|
|
30
|
+
reason: "Force clean deletes untracked files irreversibly.",
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
name: "branch-force-delete",
|
|
34
|
+
pattern: new RegExp(
|
|
35
|
+
`${GIT_PREFIX}branch\\b[^;&|\\n]*(?:\\s-[a-zA-Z]*D[a-zA-Z]*|\\s--delete\\s+--force\\b|\\s--force\\s+--delete\\b|\\s-[a-zA-Z]*d[a-zA-Z]*\\s+-[a-zA-Z]*f[a-zA-Z]*|\\s-[a-zA-Z]*f[a-zA-Z]*\\s+-[a-zA-Z]*d[a-zA-Z]*)`,
|
|
36
|
+
),
|
|
37
|
+
reason: "Force deleting a branch bypasses unmerged commit checks.",
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: "remote-branch-delete",
|
|
41
|
+
pattern: new RegExp(`${GIT_PREFIX}push\\b[^;&|\\n]*(?:\\s--delete\\b|\\s-d\\b|\\s:[\\w/.-]+)`),
|
|
42
|
+
reason: "Deleting a remote branch can impact other collaborators.",
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: "discard-all-worktree",
|
|
46
|
+
pattern: new RegExp(
|
|
47
|
+
`${GIT_PREFIX}(?:restore|checkout)\\b[^;&|\\n]*?(?:\\s(?:--\\s+)?(?:\\.|\\*|:\\/))(?=\\s|$|[;&|])`,
|
|
48
|
+
),
|
|
49
|
+
reason: "Discarding entire worktree changes loses all in-progress edits.",
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
name: "stash-destroy",
|
|
53
|
+
pattern: new RegExp(`${GIT_PREFIX}stash\\s+(?:drop|clear)\\b`),
|
|
54
|
+
reason: "Dropping or clearing stashes deletes saved work.",
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
name: "rebase-skip",
|
|
58
|
+
pattern: new RegExp(`${GIT_PREFIX}rebase\\s+--skip\\b`),
|
|
59
|
+
reason: "Rebase skip drops the conflicting commit completely.",
|
|
60
|
+
},
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
export interface SafetyOptions {
|
|
64
|
+
blockDestructiveGit?: boolean
|
|
65
|
+
allowForcePush?: boolean
|
|
66
|
+
allowStashDrop?: boolean
|
|
67
|
+
customPatterns?: { name: string; pattern: string; reason: string }[]
|
|
68
|
+
[key: string]: unknown
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function resolvePatterns(options: SafetyOptions = {}): DangerousPattern[] {
|
|
72
|
+
let list = [...DANGEROUS_GIT_PATTERNS]
|
|
73
|
+
|
|
74
|
+
if (options.allowForcePush === true) {
|
|
75
|
+
list = list.filter((p) => p.name !== "force-push")
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (options.allowStashDrop === true) {
|
|
79
|
+
list = list.filter((p) => p.name !== "stash-destroy")
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (Array.isArray(options.customPatterns)) {
|
|
83
|
+
for (const c of options.customPatterns) {
|
|
84
|
+
if (typeof c.name === "string" && typeof c.pattern === "string") {
|
|
85
|
+
try {
|
|
86
|
+
list.push({
|
|
87
|
+
name: c.name,
|
|
88
|
+
pattern: new RegExp(c.pattern),
|
|
89
|
+
reason: typeof c.reason === "string" ? c.reason : "Blocked by custom safety policy.",
|
|
90
|
+
})
|
|
91
|
+
} catch (e) {
|
|
92
|
+
console.warn(`[overclock] safety: invalid custom pattern "${c.name}": ${e}`)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return list
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function checkDangerousCommand(
|
|
102
|
+
command: string,
|
|
103
|
+
patterns: DangerousPattern[],
|
|
104
|
+
): DangerousPattern | null {
|
|
105
|
+
for (const p of patterns) {
|
|
106
|
+
if (p.pattern.test(command)) {
|
|
107
|
+
return p
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return null
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export const safety: FeatureModule = {
|
|
114
|
+
name: "safety",
|
|
115
|
+
tools: [],
|
|
116
|
+
defaultEnabled: true,
|
|
117
|
+
async init(ctx, options, shared) {
|
|
118
|
+
const opts = (options ?? {}) as SafetyOptions
|
|
119
|
+
if (opts.blockDestructiveGit === false) {
|
|
120
|
+
return {}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const patterns = resolvePatterns(opts)
|
|
124
|
+
const bashToolName = shared?.toolName ? shared.toolName("bash").toLowerCase() : "bash"
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
"tool.execute.before": async (input, output) => {
|
|
128
|
+
const toolLower = input.tool.toLowerCase()
|
|
129
|
+
if (toolLower !== "bash" && toolLower !== bashToolName) return
|
|
130
|
+
|
|
131
|
+
const args = output.args as Record<string, unknown> | undefined
|
|
132
|
+
if (!args || typeof args.command !== "string") return
|
|
133
|
+
|
|
134
|
+
const matched = checkDangerousCommand(args.command, patterns)
|
|
135
|
+
if (!matched) return
|
|
136
|
+
|
|
137
|
+
const blockedMessage = `[overclock safety] Blocked destructive git command (${matched.name}): ${matched.reason}\nCommand requested: ${args.command}\nAction: To prevent accidental code loss, destructive git actions are blocked by overclock safety policy. Use safe alternatives like 'git stash push', 'git revert', or selective file restore.`
|
|
138
|
+
|
|
139
|
+
// Rewrite command to exit with error rather than crashing the execution fiber
|
|
140
|
+
// Use shellQuote to ensure no shell expansion/substitution occurs on the blocked command
|
|
141
|
+
output.args.command = `printf '%s\\n' ${shellQuote(blockedMessage)} >&2 && exit 1`
|
|
142
|
+
|
|
143
|
+
void toast(ctx.client, `safety: blocked ${matched.name}`, "warning")
|
|
144
|
+
},
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
}
|
package/src/features/sched.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { tool } from "@opencode-ai/plugin"
|
|
2
2
|
import { Cron } from "croner"
|
|
3
|
-
import type { FeatureModule } from "../types.ts"
|
|
3
|
+
import type { FeatureModule, SchedOptions } from "../types.ts"
|
|
4
|
+
import type { BusyTracker } from "../core/types.ts"
|
|
4
5
|
import { ensureStateDir, readJson, writeJson } from "../lib/state.ts"
|
|
6
|
+
import { scheduleStore, type ScheduleEntry } from "../lib/mirror.ts"
|
|
5
7
|
import { inject, toast } from "../lib/inject.ts"
|
|
6
8
|
|
|
7
9
|
const z = tool.schema
|
|
@@ -9,103 +11,202 @@ const z = tool.schema
|
|
|
9
11
|
export type Spec = { kind: "interval"; ms: number } | { kind: "cron"; expr: string }
|
|
10
12
|
|
|
11
13
|
const UNITS: Record<string, number> = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 }
|
|
14
|
+
export const MIN_INTERVAL_MS = 5000
|
|
15
|
+
export const MAX_SCHEDULES = 50
|
|
16
|
+
export const MAX_CONSECUTIVE_FAILURES = 5
|
|
12
17
|
|
|
13
|
-
/** "30s" | "5m" | "2h" | "1d" -> interval; else cron expr (validated). Throws on garbage. */
|
|
18
|
+
/** "30s" | "5m" | "2h" | "1d" -> interval; else cron expr (validated). Throws on garbage or frequency < 5s. */
|
|
14
19
|
export function parseSpec(spec: string): Spec {
|
|
15
20
|
const m = spec.trim().match(/^(\d+)([smhd])$/)
|
|
16
|
-
if (m)
|
|
17
|
-
|
|
21
|
+
if (m) {
|
|
22
|
+
const ms = Number(m[1]) * UNITS[m[2]!]!
|
|
23
|
+
if (ms < MIN_INTERVAL_MS) {
|
|
24
|
+
throw new Error(`Schedule interval must be at least 5s (got ${spec})`)
|
|
25
|
+
}
|
|
26
|
+
return { kind: "interval", ms }
|
|
27
|
+
}
|
|
28
|
+
const cron = new Cron(spec) // throws if invalid
|
|
29
|
+
const runs = cron.nextRuns(2)
|
|
30
|
+
if (runs.length >= 2 && runs[1]!.getTime() - runs[0]!.getTime() < MIN_INTERVAL_MS) {
|
|
31
|
+
throw new Error(`Schedule frequency must be at least 5s (got ${spec})`)
|
|
32
|
+
}
|
|
18
33
|
return { kind: "cron", expr: spec }
|
|
19
34
|
}
|
|
20
35
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
36
|
+
export type Schedule = ScheduleEntry
|
|
37
|
+
|
|
38
|
+
export interface ScheduleManager {
|
|
39
|
+
readonly schedules: Map<string, ScheduleEntry>
|
|
40
|
+
create(input: {
|
|
41
|
+
spec: string
|
|
42
|
+
prompt: string
|
|
43
|
+
target: "current" | "new-session"
|
|
44
|
+
sessionID: string
|
|
45
|
+
}): Promise<{ schedule: ScheduleEntry; next: string }>
|
|
46
|
+
list(): Array<{ schedule: ScheduleEntry; next: string }>
|
|
47
|
+
delete(id: string): Promise<boolean>
|
|
48
|
+
arm(s: ScheduleEntry): void
|
|
49
|
+
disarm(id: string): void
|
|
50
|
+
fire(s: ScheduleEntry): Promise<void>
|
|
51
|
+
nextRun(s: ScheduleEntry): string
|
|
52
|
+
dispose(): void
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface ScheduleManagerDeps {
|
|
56
|
+
storePath: string
|
|
57
|
+
client: any
|
|
58
|
+
busy?: BusyTracker
|
|
59
|
+
skipIfBusy?: boolean
|
|
28
60
|
}
|
|
29
61
|
|
|
30
62
|
/**
|
|
31
|
-
*
|
|
32
|
-
*
|
|
63
|
+
* Encapsulated schedule manager handling timer registration, persistence,
|
|
64
|
+
* and dispatching prompt injections.
|
|
33
65
|
*/
|
|
34
|
-
export
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
async
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
66
|
+
export async function createScheduleManager(deps: ScheduleManagerDeps): Promise<ScheduleManager> {
|
|
67
|
+
const schedules = new Map<string, ScheduleEntry>()
|
|
68
|
+
const timers = new Map<string, { stop(): void }>()
|
|
69
|
+
const consecutiveFailures = new Map<string, number>()
|
|
70
|
+
const skipIfBusy = deps.skipIfBusy !== false
|
|
71
|
+
|
|
72
|
+
const persist = async () => writeJson(deps.storePath, [...schedules.values()])
|
|
73
|
+
|
|
74
|
+
async function remove(id: string): Promise<boolean> {
|
|
75
|
+
if (!schedules.delete(id)) return false
|
|
76
|
+
disarm(id)
|
|
77
|
+
consecutiveFailures.delete(id)
|
|
78
|
+
await persist()
|
|
79
|
+
return true
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function fire(s: ScheduleEntry) {
|
|
83
|
+
try {
|
|
84
|
+
if (s.target === "current") {
|
|
85
|
+
if (skipIfBusy && deps.busy?.isBusy(s.sessionID)) {
|
|
86
|
+
await toast(deps.client, `schedule ${s.id} skipped (session busy)`, "info")
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
const ok = await inject(deps.client, s.sessionID, `[schedule ${s.id} fired]\n${s.prompt}`)
|
|
90
|
+
if (!ok) {
|
|
91
|
+
const fails = (consecutiveFailures.get(s.id) ?? 0) + 1
|
|
92
|
+
consecutiveFailures.set(s.id, fails)
|
|
93
|
+
if (fails >= MAX_CONSECUTIVE_FAILURES) {
|
|
94
|
+
console.warn(
|
|
95
|
+
`[overclock] schedule ${s.id}: target session ${s.sessionID} unreachable ${fails} times, auto-removing`,
|
|
96
|
+
)
|
|
97
|
+
await toast(deps.client, `schedule ${s.id}: session unreachable, auto-removed`, "warning")
|
|
98
|
+
await remove(s.id)
|
|
55
99
|
return
|
|
56
100
|
}
|
|
57
|
-
|
|
58
|
-
if (!ok) await toast(ctx.client, `schedule ${s.id}: target session gone`, "warning")
|
|
101
|
+
await toast(deps.client, `schedule ${s.id}: target session gone`, "warning")
|
|
59
102
|
} else {
|
|
60
|
-
|
|
61
|
-
const id = res.data?.id
|
|
62
|
-
if (!id) throw new Error("session.create returned no id")
|
|
63
|
-
await inject(ctx.client, id, s.prompt)
|
|
64
|
-
await toast(ctx.client, `schedule ${s.id} fired -> new session`, "info")
|
|
103
|
+
consecutiveFailures.delete(s.id)
|
|
65
104
|
}
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
|
|
105
|
+
} else {
|
|
106
|
+
const res = await deps.client.session.create({ body: { title: `sched:${s.id}` } })
|
|
107
|
+
const id = res.data?.id
|
|
108
|
+
if (!id) throw new Error("session.create returned no id")
|
|
109
|
+
await inject(deps.client, id, s.prompt)
|
|
110
|
+
await toast(deps.client, `schedule ${s.id} fired -> new session`, "info")
|
|
69
111
|
}
|
|
112
|
+
} catch (e) {
|
|
113
|
+
console.warn(`[overclock] schedule ${s.id} fire failed: ${e}`)
|
|
114
|
+
await toast(deps.client, `schedule ${s.id} failed: ${e}`, "error")
|
|
70
115
|
}
|
|
116
|
+
}
|
|
71
117
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
118
|
+
function arm(s: ScheduleEntry) {
|
|
119
|
+
const spec = parseSpec(s.spec)
|
|
120
|
+
const timer =
|
|
121
|
+
spec.kind === "interval"
|
|
122
|
+
? {
|
|
123
|
+
stop: clearInterval.bind(
|
|
124
|
+
null,
|
|
125
|
+
setInterval(() => fire(s), spec.ms),
|
|
126
|
+
),
|
|
127
|
+
}
|
|
128
|
+
: new Cron(spec.expr, () => fire(s))
|
|
129
|
+
timers.set(s.id, timer)
|
|
130
|
+
}
|
|
81
131
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
timers.delete(id)
|
|
87
|
-
}
|
|
132
|
+
function disarm(id: string) {
|
|
133
|
+
timers.get(id)?.stop()
|
|
134
|
+
timers.delete(id)
|
|
135
|
+
}
|
|
88
136
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
137
|
+
function nextRun(s: ScheduleEntry): string {
|
|
138
|
+
const spec = parseSpec(s.spec)
|
|
139
|
+
if (spec.kind === "cron") return new Cron(spec.expr).nextRun()?.toISOString() ?? "never"
|
|
140
|
+
return `every ${s.spec}`
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const stored = await readJson<ScheduleEntry[]>(deps.storePath, [])
|
|
144
|
+
for (const s of stored) {
|
|
145
|
+
schedules.set(s.id, s)
|
|
146
|
+
try {
|
|
147
|
+
arm(s)
|
|
148
|
+
} catch (e) {
|
|
149
|
+
console.warn(`[overclock] rearm ${s.id} failed: ${e}`)
|
|
93
150
|
}
|
|
151
|
+
}
|
|
94
152
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
153
|
+
return {
|
|
154
|
+
schedules,
|
|
155
|
+
arm,
|
|
156
|
+
disarm,
|
|
157
|
+
fire,
|
|
158
|
+
nextRun,
|
|
159
|
+
async create(input) {
|
|
160
|
+
if (schedules.size >= MAX_SCHEDULES) {
|
|
161
|
+
throw new Error(`Maximum schedules limit reached (${MAX_SCHEDULES})`)
|
|
102
162
|
}
|
|
103
|
-
|
|
163
|
+
parseSpec(input.spec)
|
|
164
|
+
const s: ScheduleEntry = {
|
|
165
|
+
id: `s-${crypto.randomUUID().slice(0, 6)}`,
|
|
166
|
+
spec: input.spec,
|
|
167
|
+
prompt: input.prompt,
|
|
168
|
+
target: input.target,
|
|
169
|
+
sessionID: input.sessionID,
|
|
170
|
+
createdAt: new Date().toISOString(),
|
|
171
|
+
}
|
|
172
|
+
schedules.set(s.id, s)
|
|
173
|
+
arm(s)
|
|
174
|
+
await persist()
|
|
175
|
+
return { schedule: s, next: nextRun(s) }
|
|
176
|
+
},
|
|
177
|
+
list() {
|
|
178
|
+
return [...schedules.values()].map((s) => ({ schedule: s, next: nextRun(s) }))
|
|
179
|
+
},
|
|
180
|
+
delete: remove,
|
|
181
|
+
dispose() {
|
|
182
|
+
for (const id of [...timers.keys()]) disarm(id)
|
|
183
|
+
},
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Scheduled runs: cron exprs or plain intervals ("5m"). interval + current session = /loop.
|
|
189
|
+
* Persisted, rearmed on startup.
|
|
190
|
+
*/
|
|
191
|
+
export const sched: FeatureModule = {
|
|
192
|
+
name: "sched",
|
|
193
|
+
tools: ["schedule_create", "schedule_list", "schedule_delete"],
|
|
194
|
+
defaultEnabled: true,
|
|
195
|
+
requires: ["session.promptAsync", "session.messages", "session.create"],
|
|
196
|
+
async init(ctx, rawOptions, shared) {
|
|
197
|
+
const options = (rawOptions ?? {}) as SchedOptions
|
|
198
|
+
await ensureStateDir(ctx.directory)
|
|
199
|
+
const storePath = scheduleStore.path(ctx.directory)
|
|
200
|
+
|
|
201
|
+
const manager = await createScheduleManager({
|
|
202
|
+
storePath,
|
|
203
|
+
client: ctx.client,
|
|
204
|
+
busy: shared.busy,
|
|
205
|
+
skipIfBusy: options.skipIfBusy !== false,
|
|
206
|
+
})
|
|
104
207
|
|
|
105
208
|
return {
|
|
106
|
-
dispose: async () =>
|
|
107
|
-
for (const id of [...timers.keys()]) disarm(id)
|
|
108
|
-
},
|
|
209
|
+
dispose: async () => manager.dispose(),
|
|
109
210
|
tool: {
|
|
110
211
|
schedule_create: tool({
|
|
111
212
|
description:
|
|
@@ -116,31 +217,25 @@ export const sched: FeatureModule = {
|
|
|
116
217
|
target: z.enum(["current", "new-session"]).default("current"),
|
|
117
218
|
},
|
|
118
219
|
async execute(args, tctx) {
|
|
119
|
-
|
|
120
|
-
const s: Schedule = {
|
|
121
|
-
id: `s-${crypto.randomUUID().slice(0, 6)}`,
|
|
220
|
+
const { schedule, next } = await manager.create({
|
|
122
221
|
spec: args.spec,
|
|
123
222
|
prompt: args.prompt,
|
|
124
223
|
target: args.target,
|
|
125
224
|
sessionID: tctx.sessionID,
|
|
126
|
-
|
|
127
|
-
}
|
|
128
|
-
schedules.set(s.id, s)
|
|
129
|
-
arm(s)
|
|
130
|
-
await persist()
|
|
131
|
-
return `created ${s.id}: ${args.spec} -> ${args.target} (next: ${nextRun(s)})`
|
|
225
|
+
})
|
|
226
|
+
return `created ${schedule.id}: ${args.spec} -> ${args.target} (next: ${next})`
|
|
132
227
|
},
|
|
133
228
|
}),
|
|
134
229
|
schedule_list: tool({
|
|
135
230
|
description: "List schedules.",
|
|
136
231
|
args: {},
|
|
137
232
|
async execute() {
|
|
138
|
-
const all =
|
|
233
|
+
const all = manager.list()
|
|
139
234
|
if (!all.length) return "no schedules"
|
|
140
235
|
return all
|
|
141
236
|
.map(
|
|
142
|
-
(s) =>
|
|
143
|
-
`${s.id} [${s.spec}] -> ${s.target} (next: ${
|
|
237
|
+
({ schedule: s, next }) =>
|
|
238
|
+
`${s.id} [${s.spec}] -> ${s.target} (next: ${next}) :: ${s.prompt.slice(0, 60)}`,
|
|
144
239
|
)
|
|
145
240
|
.join("\n")
|
|
146
241
|
},
|
|
@@ -149,9 +244,8 @@ export const sched: FeatureModule = {
|
|
|
149
244
|
description: "Delete a schedule by id.",
|
|
150
245
|
args: { id: z.string() },
|
|
151
246
|
async execute(args) {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
await persist()
|
|
247
|
+
const ok = await manager.delete(args.id)
|
|
248
|
+
if (!ok) return `no schedule ${args.id}`
|
|
155
249
|
return `deleted ${args.id}`
|
|
156
250
|
},
|
|
157
251
|
}),
|