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
@@ -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
+ }
@@ -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) return { kind: "interval", ms: Number(m[1]) * UNITS[m[2]!]! }
17
- new Cron(spec) // throws if invalid
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
- interface Schedule {
22
- id: string
23
- spec: string
24
- prompt: string
25
- target: "current" | "new-session"
26
- sessionID: string // creator; inject target when target=current
27
- createdAt: string
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
- * Scheduled runs: cron exprs or plain intervals ("5m"). interval + current session = /loop.
32
- * Persisted, rearmed on startup.
63
+ * Encapsulated schedule manager handling timer registration, persistence,
64
+ * and dispatching prompt injections.
33
65
  */
34
- export const sched: FeatureModule = {
35
- name: "sched",
36
- tools: ["schedule_create", "schedule_list", "schedule_delete"],
37
- options: { skipIfBusy: "boolean" },
38
- defaultEnabled: true,
39
- requires: ["session.promptAsync", "session.messages", "session.create"],
40
- async init(ctx, options, shared) {
41
- const dir = await ensureStateDir(ctx.directory)
42
- const storePath = `${dir}/schedules.json`
43
- const schedules = new Map<string, Schedule>()
44
- const timers = new Map<string, Cron | ReturnType<typeof setInterval>>()
45
- const skipIfBusy = options.skipIfBusy !== false
46
-
47
- const persist = () => writeJson(storePath, [...schedules.values()])
48
-
49
- async function fire(s: Schedule) {
50
- try {
51
- if (s.target === "current") {
52
- // target still chewing on previous turn -> skip this fire, no pileup
53
- if (skipIfBusy && shared.busy.isBusy(s.sessionID)) {
54
- await toast(ctx.client, `schedule ${s.id} skipped (session busy)`, "info")
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
- const ok = await inject(ctx.client, s.sessionID, `[schedule ${s.id} fired]\n${s.prompt}`)
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
- const res = await ctx.client.session.create({ body: { title: `sched:${s.id}` } })
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
- } catch (e) {
67
- console.warn(`[overclock] schedule ${s.id} fire failed: ${e}`)
68
- await toast(ctx.client, `schedule ${s.id} failed: ${e}`, "error")
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
- function arm(s: Schedule) {
73
- const spec = parseSpec(s.spec)
74
- timers.set(
75
- s.id,
76
- spec.kind === "interval"
77
- ? setInterval(() => fire(s), spec.ms)
78
- : new Cron(spec.expr, () => fire(s)),
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
- function disarm(id: string) {
83
- const t = timers.get(id)
84
- if (!t) return
85
- t instanceof Cron ? t.stop() : clearInterval(t)
86
- timers.delete(id)
87
- }
132
+ function disarm(id: string) {
133
+ timers.get(id)?.stop()
134
+ timers.delete(id)
135
+ }
88
136
 
89
- const nextRun = (s: Schedule): string => {
90
- const spec = parseSpec(s.spec)
91
- if (spec.kind === "cron") return new Cron(spec.expr).nextRun()?.toISOString() ?? "never"
92
- return `every ${s.spec}`
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
- // rearm persisted schedules (restart-safe)
96
- for (const s of await readJson<Schedule[]>(storePath, [])) {
97
- schedules.set(s.id, s)
98
- try {
99
- arm(s)
100
- } catch (e) {
101
- console.warn(`[overclock] rearm ${s.id} failed: ${e}`)
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
- parseSpec(args.spec) // validate before storing
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
- createdAt: new Date().toISOString(),
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 = [...schedules.values()]
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: ${nextRun(s)}) :: ${s.prompt.slice(0, 60)}`,
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
- if (!schedules.delete(args.id)) return `no schedule ${args.id}`
153
- disarm(args.id)
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
  }),