-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathindex.ts
More file actions
189 lines (173 loc) · 6.28 KB
/
Copy pathindex.ts
File metadata and controls
189 lines (173 loc) · 6.28 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
/**
* opencode-plugin-loop — main entry point
*
* Implements the `/loop` command for opencode, modeled after Claude Code's `/loop`.
*
* Usage:
* /loop 5m check if the deploy finished — fixed interval (in current session)
* /loop check the deploy status — adaptive interval (1min–1hr)
* /loop — bare: read .opencode/loop.md or default maintenance
*
* Subcommands (all session-scoped; add `--all` to cross sessions):
* /loop list | status [--all] — show tasks
* /loop cancel | stop <id> [--all] — cancel one
* /loop pause <id> [--all] — pause one
* /loop resume <id> [--all] — resume one
* /loop stop-all [--all] — cancel all
*
* Per-session architecture:
* - chat.message hook tracks the current active sessionID
* - command.execute.before also updates currentSessionID
* - 15s ticker fires ONLY tasks whose sessionID === currentSessionID
* - session.deleted event cancels all tasks for that session
* - On load, tasks without sessionID (legacy) are cleaned up
*/
import type { Plugin, Hooks, PluginModule } from "@opencode-ai/plugin"
import { LoopStore } from "./store.js"
import { Scheduler } from "./scheduler.js"
import { CronParser } from "./cron-parser.js"
import { Jitter } from "./jitter.js"
import { buildLoopTools } from "./tools/loop-tools.js"
import type { LoopConfig } from "./types.js"
import {
consumeLoopCommand,
createLoopLogger,
errorMessage,
showLoopResult,
} from "./runtime-feedback.js"
const DEFAULT_CONFIG: Required<LoopConfig> = {
storageDir: "",
maxTasks: 50,
taskTtlDays: 7,
jitterPercent: 0.1,
defaultAdaptiveMinMs: 60_000,
defaultAdaptiveMaxMs: 3_600_000,
tickerIntervalMs: 5_000,
}
function commandAction(args: string): string {
const head = args.trim().split(/\s+/, 1)[0]?.toLowerCase()
if (!head) return "maintenance"
if (["list", "status", "cancel", "stop", "pause", "resume", "stop-all"].includes(head)) {
return head
}
return "schedule"
}
export const LoopPlugin: Plugin = async (ctx) => {
const logger = createLoopLogger(ctx.client)
const opts = (ctx as any).options as Partial<LoopConfig> | undefined
const config = { ...DEFAULT_CONFIG, ...(opts ?? {}) }
const storageDir = config.storageDir || `${ctx.directory}/.opencode/cache/loop`
// Use factory functions directly (not `new`) to avoid opencode's plugin
// loader eating the `new` keyword and breaking function constructors.
const store = LoopStore({
storageDir,
maxTasks: config.maxTasks,
taskTtlMs: config.taskTtlDays * 86_400_000,
logger,
})
await store.load()
const cron = CronParser()
const jitter = Jitter()
const scheduler = Scheduler({
store,
cron,
jitter,
adaptiveMinMs: config.defaultAdaptiveMinMs,
adaptiveMaxMs: config.defaultAdaptiveMaxMs,
logger,
})
// Track which session the user is currently in.
// Updated by chat.message hook (every user message) and command.execute.before.
let activeSessionID: string | null = null
const setActive = (sid: string | null | undefined): void => {
if (sid) activeSessionID = sid
}
// Internal ticker: every 15s, fire any due tasks whose sessionID matches the active session.
// This replaces the old session.idle-event-driven firing and runs even when no user input.
const inflight = new Set<string>()
const ticker = setInterval(async () => {
try {
if (!activeSessionID) return
const due = await scheduler.getDueTasksForSession(activeSessionID)
if (due.length === 0) return
for (const task of due) {
if (task.sessionID !== activeSessionID) continue
if (inflight.has(task.id)) continue
inflight.add(task.id)
try {
await scheduler.fireTask(task, ctx)
const next = await scheduler.nextDueAt(task)
await store.markFired(task.id, next)
} finally {
inflight.delete(task.id)
}
}
} catch (err) {
await logger("error", "ticker error", { error: errorMessage(err) })
}
}, config.tickerIntervalMs)
const hooks: Hooks = {
event: async ({ event }) => {
const e = event as { type?: string; properties?: any; sessionID?: string }
if (e.type === "session.compacted") {
await store.load()
return
}
if (e.type === "session.deleted") {
const sid = e.properties?.sessionID ?? e.sessionID
if (sid) {
const n = await store.cancelBySession(sid)
if (n > 0) {
await logger("info", `cleaned ${n} task(s) for deleted session`, {
sessionID: sid,
count: n,
})
}
if (activeSessionID === sid) activeSessionID = null
}
return
}
},
"chat.message": async (input) => {
setActive(input.sessionID)
},
"command.execute.before": async (input, output) => {
if (input.command !== "loop") return
setActive(input.sessionID)
consumeLoopCommand(output.parts)
const args = input.arguments || ""
let result
try {
result = await scheduler.handleUserCommand(args, ctx.directory, input.sessionID)
} catch (error) {
result = { message: `❌ /loop failed: ${errorMessage(error)}` }
}
await logger(result.message.startsWith("❌") ? "error" : "info", result.message, {
sessionID: input.sessionID,
action: commandAction(args),
argumentLength: args.length,
})
await showLoopResult(ctx.client, result, logger)
},
}
hooks.tool = await buildLoopTools(store, scheduler)
;(hooks as any)._ticker = ticker
hooks.dispose = async () => {
clearInterval(ticker)
}
return hooks
}
// OpenCode v1 detects the default {id, server} object before its legacy loader
// scans every named export. Keeping the factories below as named exports is
// therefore safe while preserving the package's public composition API.
export const plugin: PluginModule = {
id: "opencode-plugin-loop",
server: LoopPlugin,
}
export default plugin
// ---- Public API exports (for users who want to compose) ----
export { LoopStore } from "./store.js"
export { Scheduler } from "./scheduler.js"
export { CronParser } from "./cron-parser.js"
export { Jitter } from "./jitter.js"
export * from "./types.js"