@bybrawe/opencode-loop 0.5.28 → 0.5.29

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 (56) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +1 -1
  3. package/package.json +4 -4
  4. package/scripts/command-lifecycle-test.mjs +47 -0
  5. package/scripts/command-router-test.mjs +175 -0
  6. package/scripts/compaction-runtime-test.mjs +128 -0
  7. package/scripts/comprehensive-test.mjs +11 -3
  8. package/scripts/executor-runtime-test.mjs +310 -0
  9. package/scripts/fixtures/opencode2-real-adapter-probe.js +70 -0
  10. package/scripts/goal-command-handlers-test.mjs +236 -0
  11. package/scripts/goal-policy-test.mjs +143 -0
  12. package/scripts/goal-runtime-test.mjs +203 -0
  13. package/scripts/goal-steering-test.mjs +153 -0
  14. package/scripts/host-goal-steering-canary.mjs +443 -0
  15. package/scripts/host-loop-canary.mjs +54 -12
  16. package/scripts/job-workspace-test.mjs +164 -0
  17. package/scripts/loop-command-handlers-test.mjs +279 -0
  18. package/scripts/loop-registration-test.mjs +220 -0
  19. package/scripts/opencode2-loop-canary.mjs +433 -0
  20. package/scripts/scheduler-runtime-test.mjs +147 -0
  21. package/scripts/session-activity-test.mjs +95 -0
  22. package/scripts/session-status-test.mjs +220 -0
  23. package/scripts/v2-command-adapter-test.mjs +113 -0
  24. package/scripts/v2-command-runtime-test.mjs +98 -0
  25. package/scripts/v2-host-contract-test.mjs +27 -0
  26. package/scripts/v2-plugin-sentinel-test.mjs +57 -8
  27. package/scripts/v2-prompt-interval-test.mjs +171 -0
  28. package/scripts/v2-prompt-runtime-test.mjs +267 -0
  29. package/scripts/v2-runtime-adapter-test.mjs +326 -0
  30. package/src/index.js +2751 -1594
  31. package/src/source/legacy-v1.js +168 -1860
  32. package/src/source/opencode/command-router.js +103 -0
  33. package/src/source/opencode/commands.js +83 -0
  34. package/src/source/opencode/goal-commands.js +107 -0
  35. package/src/source/opencode/loop-commands.js +195 -0
  36. package/src/source/opencode/loop-registration.js +100 -0
  37. package/src/source/opencode/messages.js +55 -0
  38. package/src/source/opencode2/capabilities.js +3 -1
  39. package/src/source/opencode2/commands.js +60 -0
  40. package/src/source/opencode2/event-bridge.js +19 -1
  41. package/src/source/opencode2/events.js +64 -0
  42. package/src/source/opencode2/experimental.js +32 -1
  43. package/src/source/opencode2/host-contract.js +26 -0
  44. package/src/source/opencode2/prompt-runtime.js +381 -0
  45. package/src/source/opencode2/runtime-adapter.js +56 -0
  46. package/src/source/opencode2/status.js +40 -0
  47. package/src/source/runtime/compaction.js +122 -0
  48. package/src/source/runtime/executor.js +558 -0
  49. package/src/source/runtime/goal-policy.js +77 -0
  50. package/src/source/runtime/goal-runtime.js +231 -0
  51. package/src/source/runtime/goal-steering.js +198 -0
  52. package/src/source/runtime/job-workspace.js +148 -0
  53. package/src/source/runtime/scheduler.js +212 -0
  54. package/src/source/runtime/session-activity.js +112 -0
  55. package/src/source/runtime/session-status.js +197 -0
  56. package/scripts/host-bridge-parity-test.mjs +0 -22
@@ -18,7 +18,7 @@ export const OPENCODE_LOOP_V2_RUNTIME_REQUIREMENTS = Object.freeze([
18
18
  "runtime.adapter",
19
19
  ])
20
20
 
21
- export const OPENCODE_LOOP_V2_COMMAND_SOURCE = "file-definitions"
21
+ export const OPENCODE_LOOP_V2_COMMAND_SOURCE = "plugin-transform"
22
22
 
23
23
  export function inspectOpenCode2Context(ctx) {
24
24
  const command = ctx?.command
@@ -31,6 +31,8 @@ export function inspectOpenCode2Context(ctx) {
31
31
  eventSubscribe: hasFunction(event, "subscribe"),
32
32
  sessionHook: hasFunction(session, "hook"),
33
33
  sessionPrompt: hasFunction(session, "prompt"),
34
+ sessionCommand: hasFunction(session, "command"),
35
+ sessionShell: hasFunction(session, "shell"),
34
36
  toolTransform: hasFunction(tool, "transform"),
35
37
  toolHook: hasFunction(tool, "hook"),
36
38
  })
@@ -0,0 +1,60 @@
1
+ export const OPENCODE_LOOP_V2_COMMANDS = Object.freeze({
2
+ loop: Object.freeze({
3
+ description: "Start an OpenCode auto-continue loop. Usage: /loop 5m <task>",
4
+ template: "OpenCode Loop local command handled. Reply exactly: OK.",
5
+ }),
6
+ "loop-pause": Object.freeze({
7
+ description: "Pause OpenCode Loop jobs.",
8
+ template: "OpenCode Loop pause command handled locally. Reply exactly: OK.",
9
+ }),
10
+ "loop-resume": Object.freeze({
11
+ description: "Resume OpenCode Loop jobs.",
12
+ template: "OpenCode Loop resume command handled locally. Reply exactly: OK.",
13
+ }),
14
+ "loop-stop": Object.freeze({
15
+ description: "Stop OpenCode Loop jobs.",
16
+ template: "OpenCode Loop stop command handled locally. Reply exactly: OK.",
17
+ }),
18
+ "loop-remove": Object.freeze({
19
+ description: "Remove OpenCode Loop jobs.",
20
+ template: "OpenCode Loop remove command handled locally. Reply exactly: OK.",
21
+ }),
22
+ "loop-clear": Object.freeze({
23
+ description: "Clear all OpenCode Loop jobs.",
24
+ template: "OpenCode Loop clear command handled locally. Reply exactly: OK.",
25
+ }),
26
+ "loop-status": Object.freeze({
27
+ description: "Show OpenCode Loop status for the current session.",
28
+ template: "OpenCode Loop status command handled locally. Reply exactly: OK.",
29
+ }),
30
+ "loop-now": Object.freeze({
31
+ description: "Run matching OpenCode Loop jobs on the next idle boundary.",
32
+ template: "OpenCode Loop run-now command handled locally. Reply exactly: OK.",
33
+ }),
34
+ })
35
+
36
+ export function registerOpenCode2LoopCommands(draft) {
37
+ if (!draft || typeof draft.update !== "function") {
38
+ throw new Error("OpenCode 2 command draft.update capability is unavailable")
39
+ }
40
+ for (const [name, definition] of Object.entries(OPENCODE_LOOP_V2_COMMANDS)) {
41
+ draft.update(name, (command) => {
42
+ command.template = definition.template
43
+ command.description = definition.description
44
+ })
45
+ }
46
+ }
47
+
48
+ export function parseOpenCode2LoopCommandText(value) {
49
+ if (typeof value !== "string") return undefined
50
+ for (const [name, definition] of Object.entries(OPENCODE_LOOP_V2_COMMANDS)) {
51
+ const template = definition.template
52
+ if (value === template) return Object.freeze({ name, arguments: "" })
53
+ if (!value.startsWith(`${template}\n\n`)) continue
54
+ return Object.freeze({
55
+ name,
56
+ arguments: value.slice(template.length + 2).trim(),
57
+ })
58
+ }
59
+ return undefined
60
+ }
@@ -1,5 +1,6 @@
1
1
  import { normalizeOpenCodeEvent } from "../runtime/events.js"
2
2
  import { createSessionRuntimeManager } from "../runtime/session-manager.js"
3
+ import { normalizeOpenCode2NativeEvent } from "./events.js"
3
4
 
4
5
  function cleanupRegistration(registration) {
5
6
  if (typeof registration === "function") return registration
@@ -41,6 +42,7 @@ export function createOpenCode2EventBridge({
41
42
  let disposed = false
42
43
  let managerDisposed = false
43
44
  let queue = Promise.resolve()
45
+ const sessionDirectories = new Map()
44
46
 
45
47
  function report(error) {
46
48
  try { onError(error) } catch {}
@@ -61,20 +63,35 @@ export function createOpenCode2EventBridge({
61
63
  return Boolean(current)
62
64
  }
63
65
 
66
+ function normalize(raw) {
67
+ const current = normalizeOpenCode2NativeEvent(raw) || normalizeOpenCodeEvent(raw)
68
+ if (!current) return undefined
69
+ const sessionID = current.sessionID
70
+ const rememberedDirectory = sessionID ? sessionDirectories.get(sessionID) : undefined
71
+ const eventDirectory = current.directory || rememberedDirectory || directory
72
+ const event = eventDirectory === current.directory
73
+ ? current
74
+ : Object.freeze({ ...current, directory: eventDirectory })
75
+ if (sessionID && event.directory) sessionDirectories.set(sessionID, event.directory)
76
+ return event
77
+ }
78
+
64
79
  async function process(raw) {
65
80
  if (stopped) return undefined
66
- const event = normalizeOpenCodeEvent(raw)
81
+ const event = normalize(raw)
67
82
  if (!event || !sameDirectory(directory, event.directory)) return undefined
68
83
 
69
84
  const runtime = event.sessionID ? runtimeManager.observeExternal(event.sessionID) : undefined
70
85
  await onEvent(event, runtime)
71
86
 
72
87
  if (event.kind === "session" && event.action === "deleted") {
88
+ sessionDirectories.delete(event.sessionID)
73
89
  runtimeManager.remove(event.sessionID, { expectedRuntime: runtime, reason: "session-deleted" })
74
90
  }
75
91
 
76
92
  if (event.kind === "server" && event.action === "disposed") {
77
93
  stopped = true
94
+ sessionDirectories.clear()
78
95
  disposeManager("server-disposed")
79
96
  }
80
97
 
@@ -141,6 +158,7 @@ export function createOpenCode2EventBridge({
141
158
  const cleanup = cleanupRegistration(registration)
142
159
  registration = undefined
143
160
  if (cleanup) await cleanup()
161
+ sessionDirectories.clear()
144
162
  disposeManager(reason)
145
163
  return true
146
164
  }
@@ -0,0 +1,64 @@
1
+ import { parseOpenCode2LoopCommandText } from "./commands.js"
2
+
3
+ function record(value) {
4
+ return value && typeof value === "object" ? value : undefined
5
+ }
6
+
7
+ function text(value) {
8
+ return typeof value === "string" && value.trim() ? value : undefined
9
+ }
10
+
11
+ function directoryFrom(raw) {
12
+ return text(record(raw?.location)?.directory)
13
+ }
14
+
15
+ function sessionIDFrom(raw) {
16
+ const data = record(raw?.data)
17
+ return text(data?.sessionID)
18
+ }
19
+
20
+ export function normalizeOpenCode2NativeEvent(raw) {
21
+ const type = text(raw?.type)
22
+ if (!type) return undefined
23
+
24
+ const data = record(raw?.data) || {}
25
+ const directory = directoryFrom(raw)
26
+ const sessionID = sessionIDFrom(raw)
27
+
28
+ if (type === "session.inbox.enqueued") {
29
+ const item = record(data.item)
30
+ const payload = record(item?.payload)
31
+ const parsed = item?.type === "user" ? parseOpenCode2LoopCommandText(payload?.text) : undefined
32
+ if (!sessionID || !parsed) return undefined
33
+ return Object.freeze({
34
+ kind: "command",
35
+ action: "executed",
36
+ sessionID,
37
+ directory,
38
+ name: parsed.name,
39
+ arguments: parsed.arguments,
40
+ })
41
+ }
42
+
43
+ if (type === "session.execution.started") {
44
+ if (!sessionID) return undefined
45
+ return Object.freeze({ kind: "session", action: "status", sessionID, directory, status: "busy" })
46
+ }
47
+
48
+ if (type === "session.execution.succeeded") {
49
+ if (!sessionID) return undefined
50
+ return Object.freeze({ kind: "session", action: "idle", sessionID, directory })
51
+ }
52
+
53
+ if (type === "session.created") {
54
+ if (!sessionID) return undefined
55
+ return Object.freeze({ kind: "session", action: "created", sessionID, directory: directory || text(record(data.location)?.directory) })
56
+ }
57
+
58
+ if (type === "session.deleted") {
59
+ if (!sessionID) return undefined
60
+ return Object.freeze({ kind: "session", action: "deleted", sessionID, directory })
61
+ }
62
+
63
+ return undefined
64
+ }
@@ -1,6 +1,10 @@
1
1
  import { inspectOpenCode2Context } from "./capabilities.js"
2
+ import { OPENCODE_LOOP_V2_COMMANDS, registerOpenCode2LoopCommands } from "./commands.js"
3
+ import { createOpenCode2PromptRuntime } from "./prompt-runtime.js"
4
+ import { createOpenCode2RuntimeAdapter } from "./runtime-adapter.js"
2
5
 
3
6
  export const OPENCODE_LOOP_V2_PLUGIN_ID = "bybrawe.opencode-loop.v2.experimental"
7
+ export { OPENCODE_LOOP_V2_COMMANDS }
4
8
 
5
9
  export const OpenCodeLoopV2ExperimentalPlugin = {
6
10
  id: OPENCODE_LOOP_V2_PLUGIN_ID,
@@ -9,7 +13,34 @@ export const OpenCodeLoopV2ExperimentalPlugin = {
9
13
  if (!capabilities.commandTransform) {
10
14
  throw new Error("OpenCode 2 command.transform capability is unavailable")
11
15
  }
12
- await ctx.command.transform(() => {})
16
+
17
+ const commandRegistration = await ctx.command.transform(registerOpenCode2LoopCommands)
18
+ if (!capabilities.eventSubscribe || !capabilities.sessionPrompt) {
19
+ await commandRegistration?.dispose?.()
20
+ return undefined
21
+ }
22
+
23
+ let promptRuntime
24
+ const onRuntimeEvent = async (event) => promptRuntime?.onEvent(event)
25
+ const runtime = createOpenCode2RuntimeAdapter(ctx, { onEvent: onRuntimeEvent })
26
+ promptRuntime = createOpenCode2PromptRuntime({
27
+ prompt: (request) => runtime.prompt(request),
28
+ command: capabilities.sessionCommand ? (request) => runtime.command(request) : undefined,
29
+ })
30
+
31
+ try {
32
+ await runtime.start()
33
+ } catch (error) {
34
+ await promptRuntime?.dispose?.().catch(() => undefined)
35
+ await commandRegistration?.dispose?.().catch(() => undefined)
36
+ throw error
37
+ }
38
+
39
+ return async () => {
40
+ await promptRuntime?.dispose?.()
41
+ await runtime.dispose("plugin-cleanup")
42
+ await commandRegistration?.dispose?.()
43
+ }
13
44
  },
14
45
  }
15
46
 
@@ -16,6 +16,22 @@ function normalizePrompt(input) {
16
16
  })
17
17
  }
18
18
 
19
+ function normalizeCommand(input) {
20
+ const sessionID = String(input?.sessionID || "").trim()
21
+ const command = String(input?.command || "").trim().replace(/^\/+/, "")
22
+ if (!sessionID) throw new TypeError("command requires a session ID")
23
+ if (!command) throw new TypeError("command requires a command name")
24
+ return Object.freeze({
25
+ sessionID,
26
+ command,
27
+ arguments: input?.arguments === undefined ? undefined : String(input.arguments),
28
+ agent: input?.agent,
29
+ model: input?.model,
30
+ delivery: input?.delivery,
31
+ resume: input?.resume,
32
+ })
33
+ }
34
+
19
35
  export function createOpenCode2HostContract(options = {}) {
20
36
  const onEvent = typeof options.onEvent === "function" ? options.onEvent : async () => {}
21
37
  let started = false
@@ -49,6 +65,15 @@ export function createOpenCode2HostContract(options = {}) {
49
65
  return await options.sendPrompt(Object.freeze({ ...request, runtime }))
50
66
  }
51
67
 
68
+ async function command(input) {
69
+ if (disposed || hostDisposed) throw new Error("OpenCode 2 host contract is unavailable")
70
+ if (!started) throw new Error("OpenCode 2 host contract is not started")
71
+ if (typeof options.sendCommand !== "function") throw new Error("OpenCode 2 session.command capability is unavailable")
72
+ const request = normalizeCommand(input)
73
+ const runtime = bridge.runtimeManager.observeExternal(request.sessionID)
74
+ return await options.sendCommand(Object.freeze({ ...request, runtime }))
75
+ }
76
+
52
77
  async function dispose(reason = "host-contract-disposed") {
53
78
  if (disposed) return false
54
79
  disposed = true
@@ -59,6 +84,7 @@ export function createOpenCode2HostContract(options = {}) {
59
84
  return Object.freeze({
60
85
  start,
61
86
  prompt,
87
+ command,
62
88
  dispose,
63
89
  runtimeManager: bridge.runtimeManager,
64
90
  isStarted: () => started,
@@ -0,0 +1,381 @@
1
+ import { parseLoopArgs, splitFirst } from "../core/args.js"
2
+ import { actionKind, decoratePrompt, matchJob } from "../core/jobs.js"
3
+ import { readState, removeState, writeState } from "../core/state.js"
4
+ import { formatOpenCode2LoopStatus } from "./status.js"
5
+
6
+ export const OPENCODE_LOOP_V2_PROMPT_RUNTIME = "prompt-zero-interval"
7
+ export const OPENCODE_LOOP_V2_INTERVAL_RUNTIME = "idle-safe-timer"
8
+ export const OPENCODE_LOOP_V2_PROMPT_PREFIX = "AUTONOMOUS OPENCODE LOOP ITERATION. Continue the configured task now. Do not explain the /loop command. Do not search for documentation about this plugin. Do not create scheduler files. Do not ask questions. Make reasonable assumptions and work directly."
9
+
10
+ function directoryFrom(event) {
11
+ return typeof event?.directory === "string" && event.directory.trim() ? event.directory : undefined
12
+ }
13
+
14
+ function commandParts(action) {
15
+ const normalized = String(action || "").trim().replace(/^\/+/, "")
16
+ return splitFirst(normalized)
17
+ }
18
+
19
+ function unsupportedRuntimeJob(job, supportsCommand) {
20
+ const blockers = []
21
+ const kind = actionKind(job?.action, job)
22
+ if (!String(job?.action || "").trim()) blockers.push("action")
23
+ if (kind === "command") {
24
+ if (!supportsCommand) blockers.push("command-capability")
25
+ if (!commandParts(job?.action)[0]) blockers.push("action")
26
+ } else if (kind !== "prompt") {
27
+ blockers.push("kind")
28
+ }
29
+ if (job?.watchPaths?.length) blockers.push("watch")
30
+ if (job?.promptFile) blockers.push("prompt-file")
31
+ if (job?.includeFiles?.length) blockers.push("include-file")
32
+ if (job?.verifyCommand) blockers.push("verify")
33
+ if (job?.preflightCommand) blockers.push("preflight")
34
+ if (job?.postrunCommand) blockers.push("postrun")
35
+ if (job?.notifyCommand) blockers.push("notify")
36
+ if (job?.branch) blockers.push("branch")
37
+ if (job?.compactEveryRuns > 0 || job?.compactEveryMs > 0) blockers.push("compact-every")
38
+ if (job?.gitCheckpoint || job?.checkpointOnly) blockers.push("checkpoint")
39
+ if (job?.timeoutMs > 0) blockers.push("timeout")
40
+ if (job?.maxRuntimeMs > 0) blockers.push("max-runtime")
41
+ if (job?.maxFailures > 0) blockers.push("max-failures")
42
+ if (job?.until) blockers.push("until")
43
+ if (job?.stopFile) blockers.push("stop-file")
44
+ return [...new Set(blockers)]
45
+ }
46
+
47
+ function jobName(job) {
48
+ return String(job?.name || "default")
49
+ }
50
+
51
+ function promptText(job) {
52
+ return `${OPENCODE_LOOP_V2_PROMPT_PREFIX}\n\n${decoratePrompt(job)}`
53
+ }
54
+
55
+ function commandTarget(event, fallback = "all") {
56
+ return String(event?.arguments || "").trim() || fallback
57
+ }
58
+
59
+ function scopeFrom(event) {
60
+ const directory = directoryFrom(event)
61
+ const sessionID = String(event?.sessionID || "").trim()
62
+ if (!directory || !sessionID) return undefined
63
+ return { directory, sessionID, key: `${directory}\u0000${sessionID}` }
64
+ }
65
+
66
+ function runNowRequested(job) {
67
+ return Number(job?.runNowRequestedAt || 0) > 0
68
+ }
69
+
70
+ function dueAt(job, current) {
71
+ if (runNowRequested(job)) return current
72
+ const intervalMs = Math.max(0, Number(job?.intervalMs || 0))
73
+ const lastRunAt = Number(job?.lastRunAt || 0)
74
+ if (intervalMs === 0) return current
75
+ if (lastRunAt <= 0) {
76
+ if (job?.immediate === false) {
77
+ const createdAt = Date.parse(job?.createdAt || "")
78
+ return (Number.isFinite(createdAt) ? createdAt : current) + intervalMs
79
+ }
80
+ return current
81
+ }
82
+ return lastRunAt + intervalMs
83
+ }
84
+
85
+ export function createOpenCode2PromptRuntime(options = {}) {
86
+ if (typeof options.prompt !== "function") throw new TypeError("V2 prompt runtime requires prompt()")
87
+
88
+ const supportsCommand = typeof options.command === "function"
89
+ const now = typeof options.now === "function" ? options.now : Date.now
90
+ const setTimer = typeof options.setTimer === "function" ? options.setTimer : setTimeout
91
+ const clearTimer = typeof options.clearTimer === "function" ? options.clearTimer : clearTimeout
92
+ const onError = typeof options.onError === "function" ? options.onError : () => {}
93
+ const timers = new Map()
94
+ const idle = new Map()
95
+ const queues = new Map()
96
+ let disposed = false
97
+
98
+ function report(error) {
99
+ try { onError(error) } catch {}
100
+ }
101
+
102
+ function eligibleRuntimeJob(job) {
103
+ if (!job?.enabled || job?.paused) return false
104
+ if (unsupportedRuntimeJob(job, supportsCommand).length) return false
105
+ return !(job.maxRuns > 0 && (job.runCount || 0) >= job.maxRuns)
106
+ }
107
+
108
+ function clearScopeTimer(key) {
109
+ const current = timers.get(key)
110
+ if (!current) return false
111
+ timers.delete(key)
112
+ try { clearTimer(current.handle) } catch {}
113
+ return true
114
+ }
115
+
116
+ function clearScope(scope) {
117
+ clearScopeTimer(scope.key)
118
+ idle.delete(scope.key)
119
+ }
120
+
121
+ function enqueueScope(scope, task) {
122
+ if (disposed) return Promise.resolve({ handled: false, reason: "disposed" })
123
+ const previous = queues.get(scope.key) || Promise.resolve()
124
+ const result = previous.catch(() => undefined).then(async () => {
125
+ if (disposed) return { handled: false, reason: "disposed" }
126
+ return await task()
127
+ })
128
+ const tail = result.catch(() => undefined)
129
+ queues.set(scope.key, tail)
130
+ tail.then(() => {
131
+ if (queues.get(scope.key) === tail) queues.delete(scope.key)
132
+ })
133
+ return result
134
+ }
135
+
136
+ async function scheduleScope(scope) {
137
+ clearScopeTimer(scope.key)
138
+ if (disposed) return undefined
139
+
140
+ const state = await readState(scope.directory, scope.sessionID)
141
+ const current = now()
142
+ let earliest
143
+ for (const job of state.jobs || []) {
144
+ if (!eligibleRuntimeJob(job)) continue
145
+ const intervalMs = Math.max(0, Number(job?.intervalMs || 0))
146
+ if (intervalMs === 0) continue
147
+ const candidate = dueAt(job, current)
148
+ if (candidate <= current) continue
149
+ if (!earliest || candidate < earliest) earliest = candidate
150
+ }
151
+ if (!earliest) return undefined
152
+
153
+ const delay = Math.max(0, earliest - current)
154
+ const token = Symbol(scope.key)
155
+ const handle = setTimer(() => {
156
+ const pending = enqueueScope(scope, async () => {
157
+ const currentTimer = timers.get(scope.key)
158
+ if (!currentTimer || currentTimer.token !== token) return { handled: false, reason: "stale-timer" }
159
+ timers.delete(scope.key)
160
+ if (idle.get(scope.key) !== true) return { handled: true, dispatched: false, reason: "not-idle" }
161
+ return await runDueAction(scope)
162
+ })
163
+ pending.catch(report)
164
+ return pending
165
+ }, delay)
166
+ handle?.unref?.()
167
+ timers.set(scope.key, { handle, token, dueAt: earliest })
168
+ return earliest
169
+ }
170
+
171
+ async function dispatchJob(scope, job) {
172
+ const kind = actionKind(job?.action, job)
173
+ if (kind === "command") {
174
+ const [command, argumentsText] = commandParts(job.action)
175
+ const request = {
176
+ sessionID: scope.sessionID,
177
+ command,
178
+ arguments: argumentsText || undefined,
179
+ }
180
+ await options.command(request)
181
+ return { kind, request }
182
+ }
183
+
184
+ const text = promptText(job)
185
+ const request = { sessionID: scope.sessionID, text }
186
+ await options.prompt(request)
187
+ return { kind: "prompt", request, text }
188
+ }
189
+
190
+ async function runDueAction(scope) {
191
+ const state = await readState(scope.directory, scope.sessionID)
192
+ const current = now()
193
+ const due = (state.jobs || []).filter((candidate) => eligibleRuntimeJob(candidate) && dueAt(candidate, current) <= current)
194
+ const job = due.find(runNowRequested) || due[0]
195
+ if (!job) {
196
+ await scheduleScope(scope)
197
+ return { handled: true, dispatched: false }
198
+ }
199
+
200
+ delete job.runNowRequestedAt
201
+ job.lastRunAt = current
202
+ job.runCount = (job.runCount || 0) + 1
203
+ if (job.maxRuns > 0 && job.runCount >= job.maxRuns) job.enabled = false
204
+ state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
205
+ await writeState(scope.directory, scope.sessionID, state)
206
+
207
+ idle.set(scope.key, false)
208
+ await scheduleScope(scope)
209
+
210
+ const dispatched = await dispatchJob(scope, job)
211
+ return { handled: true, dispatched: true, job, ...dispatched }
212
+ }
213
+
214
+ async function addPromptLoop(event) {
215
+ const scope = scopeFrom(event)
216
+ if (!scope) return { handled: false, reason: "missing-scope" }
217
+ return await enqueueScope(scope, async () => {
218
+ const parsed = parseLoopArgs(event.arguments || "")
219
+ if (!parsed.ok) return { handled: true, accepted: false, reason: "parse", error: parsed.error }
220
+
221
+ const blockers = unsupportedRuntimeJob(parsed.job, supportsCommand)
222
+ if (blockers.length) return { handled: true, accepted: false, reason: "unsupported", blockers }
223
+
224
+ parsed.job.name = jobName(parsed.job)
225
+ const state = await readState(scope.directory, scope.sessionID)
226
+ const jobs = Array.isArray(state.jobs) ? state.jobs : []
227
+ if (!parsed.job.multi) {
228
+ state.jobs = jobs.filter((job) => jobName(job) !== parsed.job.name)
229
+ } else {
230
+ state.jobs = jobs
231
+ }
232
+ state.jobs.push(parsed.job)
233
+ await writeState(scope.directory, scope.sessionID, state)
234
+ await scheduleScope(scope)
235
+ return { handled: true, accepted: true, job: parsed.job }
236
+ })
237
+ }
238
+
239
+ async function statusPromptLoops(event) {
240
+ const scope = scopeFrom(event)
241
+ if (!scope) return { handled: false, reason: "missing-scope" }
242
+ return await enqueueScope(scope, async () => {
243
+ const status = formatOpenCode2LoopStatus(await readState(scope.directory, scope.sessionID), now())
244
+ const request = { sessionID: scope.sessionID, text: status.text, noReply: true }
245
+ await options.prompt(request)
246
+ return { handled: true, accepted: true, status, request }
247
+ })
248
+ }
249
+
250
+ async function runPromptLoopsNow(event) {
251
+ const scope = scopeFrom(event)
252
+ if (!scope) return { handled: false, reason: "missing-scope" }
253
+ return await enqueueScope(scope, async () => {
254
+ const target = commandTarget(event)
255
+ const state = await readState(scope.directory, scope.sessionID)
256
+ const requestedAt = Math.max(1, now())
257
+ let count = 0
258
+ state.jobs = (state.jobs || []).map((job, index) => {
259
+ if (!matchJob(job, target, index)) return job
260
+ const resumed = { ...job, paused: false }
261
+ if (!eligibleRuntimeJob(resumed)) return job
262
+ count += 1
263
+ return { ...resumed, runNowRequestedAt: requestedAt }
264
+ })
265
+ await writeState(scope.directory, scope.sessionID, state)
266
+ await scheduleScope(scope)
267
+ return { handled: true, accepted: true, count, target, requestedAt: count ? requestedAt : undefined }
268
+ })
269
+ }
270
+
271
+ async function updatePromptLoops(event, updater) {
272
+ const scope = scopeFrom(event)
273
+ if (!scope) return { handled: false, reason: "missing-scope" }
274
+ return await enqueueScope(scope, async () => {
275
+ const target = commandTarget(event)
276
+ const state = await readState(scope.directory, scope.sessionID)
277
+ let count = 0
278
+ state.jobs = (state.jobs || []).map((job, index) => {
279
+ if (!matchJob(job, target, index)) return job
280
+ count += 1
281
+ return updater(job)
282
+ })
283
+ await writeState(scope.directory, scope.sessionID, state)
284
+ await scheduleScope(scope)
285
+ return { handled: true, accepted: true, count, target }
286
+ })
287
+ }
288
+
289
+ async function stopPromptLoops(event, forcedTarget) {
290
+ const scope = scopeFrom(event)
291
+ if (!scope) return { handled: false, reason: "missing-scope" }
292
+ return await enqueueScope(scope, async () => {
293
+ const target = forcedTarget || commandTarget(event)
294
+ if (target.toLowerCase() === "all") {
295
+ const state = await readState(scope.directory, scope.sessionID)
296
+ const count = (state.jobs || []).length
297
+ await removeState(scope.directory, scope.sessionID)
298
+ await scheduleScope(scope)
299
+ return { handled: true, accepted: true, count, target }
300
+ }
301
+
302
+ const state = await readState(scope.directory, scope.sessionID)
303
+ const before = (state.jobs || []).length
304
+ state.jobs = (state.jobs || []).filter((job, index) => !matchJob(job, target, index))
305
+ await writeState(scope.directory, scope.sessionID, state)
306
+ await scheduleScope(scope)
307
+ return { handled: true, accepted: true, count: before - state.jobs.length, target }
308
+ })
309
+ }
310
+
311
+ async function runIdlePrompt(event) {
312
+ const scope = scopeFrom(event)
313
+ if (!scope) return { handled: false, reason: "missing-scope" }
314
+ idle.set(scope.key, true)
315
+ return await enqueueScope(scope, () => runDueAction(scope))
316
+ }
317
+
318
+ async function onEvent(event) {
319
+ const scope = scopeFrom(event)
320
+ if (event?.kind === "command" && event?.action === "executed") {
321
+ if (scope) idle.set(scope.key, false)
322
+ if (event?.name === "loop") return addPromptLoop(event)
323
+ if (event?.name === "loop-status") return statusPromptLoops(event)
324
+ if (event?.name === "loop-now") return runPromptLoopsNow(event)
325
+ if (event?.name === "loop-pause") return updatePromptLoops(event, (job) => ({ ...job, paused: true }))
326
+ if (event?.name === "loop-resume") return updatePromptLoops(event, (job) => ({ ...job, paused: false, lastRunAt: 0 }))
327
+ if (["loop-stop", "loop-remove"].includes(event?.name)) return stopPromptLoops(event)
328
+ if (event?.name === "loop-clear") return stopPromptLoops(event, "all")
329
+ }
330
+
331
+ if (event?.kind === "session" && event?.action === "idle") {
332
+ return runIdlePrompt(event)
333
+ }
334
+ if (event?.kind === "session" && event?.action === "status" && scope) {
335
+ idle.set(scope.key, event.status === "idle")
336
+ return await enqueueScope(scope, async () => {
337
+ await scheduleScope(scope)
338
+ return { handled: true, dispatched: false }
339
+ })
340
+ }
341
+ if (event?.kind === "session" && event?.action === "deleted" && scope) {
342
+ clearScope(scope)
343
+ return { handled: true, disposedScope: true }
344
+ }
345
+ if (event?.kind === "message" && event?.action === "updated" && scope) {
346
+ if (event.role === "user" || (event.role === "assistant" && !event.completedAt)) idle.set(scope.key, false)
347
+ return { handled: false }
348
+ }
349
+ if (event?.kind === "server" && event?.action === "disposed") {
350
+ await dispose()
351
+ return { handled: true, disposed: true }
352
+ }
353
+ return { handled: false }
354
+ }
355
+
356
+ async function dispose() {
357
+ if (disposed) return false
358
+ disposed = true
359
+ for (const { handle } of timers.values()) {
360
+ try { clearTimer(handle) } catch {}
361
+ }
362
+ timers.clear()
363
+ idle.clear()
364
+ const pending = [...queues.values()]
365
+ if (pending.length) await Promise.allSettled(pending)
366
+ queues.clear()
367
+ return true
368
+ }
369
+
370
+ return Object.freeze({
371
+ onEvent,
372
+ addPromptLoop,
373
+ statusPromptLoops,
374
+ runPromptLoopsNow,
375
+ updatePromptLoops,
376
+ stopPromptLoops,
377
+ runIdlePrompt,
378
+ dispose,
379
+ scheduledCount: () => timers.size,
380
+ })
381
+ }