@bybrawe/opencode-loop 0.5.27 → 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 (80) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +1 -1
  3. package/package.json +7 -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 +29 -7
  8. package/scripts/comprehensive-watchdog.mjs +32 -0
  9. package/scripts/executor-runtime-test.mjs +310 -0
  10. package/scripts/fixtures/opencode2-real-adapter-probe.js +70 -0
  11. package/scripts/goal-command-handlers-test.mjs +236 -0
  12. package/scripts/goal-policy-test.mjs +143 -0
  13. package/scripts/goal-runtime-test.mjs +203 -0
  14. package/scripts/goal-steering-test.mjs +153 -0
  15. package/scripts/host-adapter-contract-test.mjs +147 -0
  16. package/scripts/host-goal-steering-canary.mjs +443 -0
  17. package/scripts/host-loop-canary.mjs +68 -12
  18. package/scripts/job-workspace-test.mjs +164 -0
  19. package/scripts/loop-command-handlers-test.mjs +279 -0
  20. package/scripts/loop-registration-test.mjs +220 -0
  21. package/scripts/opencode2-host-canary.mjs +194 -0
  22. package/scripts/opencode2-loop-canary.mjs +433 -0
  23. package/scripts/runtime-contract-test.mjs +59 -0
  24. package/scripts/runtime-event-test.mjs +78 -0
  25. package/scripts/runtime-scope-test.mjs +46 -0
  26. package/scripts/scheduler-runtime-test.mjs +147 -0
  27. package/scripts/session-activity-test.mjs +95 -0
  28. package/scripts/session-manager-test.mjs +48 -0
  29. package/scripts/session-status-test.mjs +220 -0
  30. package/scripts/v2-command-adapter-test.mjs +113 -0
  31. package/scripts/v2-command-runtime-test.mjs +98 -0
  32. package/scripts/v2-event-bridge-test.mjs +72 -0
  33. package/scripts/v2-event-stream-contract-test.mjs +38 -0
  34. package/scripts/v2-host-contract-test.mjs +82 -0
  35. package/scripts/v2-plugin-sentinel-test.mjs +136 -0
  36. package/scripts/v2-prompt-interval-test.mjs +171 -0
  37. package/scripts/v2-prompt-runtime-test.mjs +267 -0
  38. package/scripts/v2-runtime-adapter-test.mjs +326 -0
  39. package/src/index.js +3737 -2220
  40. package/src/opencode/v1.js +1 -0
  41. package/src/source/core/args.js +281 -0
  42. package/src/source/core/jobs.js +80 -0
  43. package/src/source/core/process.js +51 -0
  44. package/src/source/core/state.js +211 -0
  45. package/src/source/legacy-v1.js +252 -0
  46. package/src/source/opencode/adapter.js +21 -0
  47. package/src/source/opencode/command-router.js +103 -0
  48. package/src/source/opencode/commands.js +83 -0
  49. package/src/source/opencode/goal-commands.js +107 -0
  50. package/src/source/opencode/host.js +134 -0
  51. package/src/source/opencode/loop-commands.js +195 -0
  52. package/src/source/opencode/loop-registration.js +100 -0
  53. package/src/source/opencode/messages.js +55 -0
  54. package/src/source/opencode/sdk.js +38 -0
  55. package/src/source/opencode/session-context.js +50 -0
  56. package/src/source/opencode2/capabilities.js +71 -0
  57. package/src/source/opencode2/commands.js +60 -0
  58. package/src/source/opencode2/event-bridge.js +174 -0
  59. package/src/source/opencode2/events.js +64 -0
  60. package/src/source/opencode2/experimental.js +47 -0
  61. package/src/source/opencode2/host-contract.js +94 -0
  62. package/src/source/opencode2/prompt-runtime.js +381 -0
  63. package/src/source/opencode2/runtime-adapter.js +56 -0
  64. package/src/source/opencode2/status.js +40 -0
  65. package/src/source/runtime/compaction.js +122 -0
  66. package/src/source/runtime/events.js +113 -0
  67. package/src/source/runtime/executor.js +558 -0
  68. package/src/source/runtime/goal-policy.js +77 -0
  69. package/src/source/runtime/goal-runtime.js +231 -0
  70. package/src/source/runtime/goal-steering.js +198 -0
  71. package/src/source/runtime/job-workspace.js +148 -0
  72. package/src/source/runtime/observer.js +23 -0
  73. package/src/source/runtime/scheduler.js +212 -0
  74. package/src/source/runtime/scope.js +56 -0
  75. package/src/source/runtime/session-activity.js +112 -0
  76. package/src/source/runtime/session-manager.js +100 -0
  77. package/src/source/runtime/session-registry.js +58 -0
  78. package/src/source/runtime/session-status.js +197 -0
  79. package/src/source/runtime/timers.js +45 -0
  80. package/src/source/v1.js +69 -0
@@ -0,0 +1,171 @@
1
+ import assert from "node:assert/strict"
2
+ import { mkdtemp, readFile, rm } from "node:fs/promises"
3
+ import os from "node:os"
4
+ import path from "node:path"
5
+ import { createOpenCode2PromptRuntime } from "../src/source/opencode2/prompt-runtime.js"
6
+
7
+ const directory = await mkdtemp(path.join(os.tmpdir(), "opencode-loop-v2-interval-"))
8
+ let clock = 1_000
9
+ let nextTimerID = 1
10
+ const timers = new Map()
11
+ const prompts = []
12
+ const errors = []
13
+
14
+ function setTimer(callback, delay) {
15
+ const handle = {
16
+ id: nextTimerID++,
17
+ unref() {},
18
+ }
19
+ timers.set(handle.id, { handle, callback, delay, dueAt: clock + delay })
20
+ return handle
21
+ }
22
+
23
+ function clearTimer(handle) {
24
+ timers.delete(handle?.id)
25
+ }
26
+
27
+ async function fireEarliestTimer() {
28
+ const timer = [...timers.values()].sort((a, b) => a.dueAt - b.dueAt)[0]
29
+ assert.ok(timer, "expected a scheduled timer")
30
+ clock = timer.dueAt
31
+ timers.delete(timer.handle.id)
32
+ return await timer.callback()
33
+ }
34
+
35
+ async function readState(sessionID) {
36
+ const file = path.join(directory, ".opencode", "opencode-loop", `${sessionID}.json`)
37
+ try {
38
+ return JSON.parse(await readFile(file, "utf8"))
39
+ } catch (error) {
40
+ if (error?.code === "ENOENT") return { jobs: [] }
41
+ throw error
42
+ }
43
+ }
44
+
45
+ const runtime = createOpenCode2PromptRuntime({
46
+ prompt: async (request) => { prompts.push({ ...request, at: clock }) },
47
+ now: () => clock,
48
+ setTimer,
49
+ clearTimer,
50
+ onError: (error) => { errors.push(error) },
51
+ })
52
+
53
+ try {
54
+ const sessionID = "ses_interval"
55
+ const added = await runtime.onEvent({
56
+ kind: "command",
57
+ action: "executed",
58
+ name: "loop",
59
+ sessionID,
60
+ directory,
61
+ arguments: "5s --max-runs 3 interval work",
62
+ })
63
+ assert.equal(added.accepted, true)
64
+ assert.equal(added.job.intervalMs, 5_000)
65
+ assert.equal(runtime.scheduledCount(), 0, "first interval run waits for an idle event")
66
+
67
+ const statusIdle = await runtime.onEvent({ kind: "session", action: "status", status: "idle", sessionID, directory })
68
+ assert.equal(statusIdle.dispatched, false, "status=idle records readiness but must not duplicate session.idle dispatch")
69
+ assert.equal(prompts.length, 0)
70
+
71
+ const first = await runtime.onEvent({ kind: "session", action: "idle", sessionID, directory })
72
+ assert.equal(first.dispatched, true)
73
+ assert.equal(prompts.length, 1)
74
+ assert.equal(prompts[0].at, 1_000)
75
+ assert.equal(runtime.scheduledCount(), 1)
76
+ assert.equal(timers.size, 1)
77
+ assert.equal([...timers.values()][0].delay, 5_000)
78
+
79
+ let state = await readState(sessionID)
80
+ assert.equal(state.jobs[0].runCount, 1)
81
+ assert.equal(state.jobs[0].lastRunAt, 1_000)
82
+ assert.equal(state.jobs[0].enabled, true)
83
+
84
+ await runtime.onEvent({ kind: "session", action: "status", status: "busy", sessionID, directory })
85
+ const busyTimer = await fireEarliestTimer()
86
+ assert.equal(busyTimer.dispatched, false)
87
+ assert.equal(busyTimer.reason, "not-idle")
88
+ assert.equal(prompts.length, 1, "due timer must never prompt while session is known busy")
89
+ assert.equal(runtime.scheduledCount(), 0, "due work waits for the next idle event instead of spinning timers")
90
+
91
+ const second = await runtime.onEvent({ kind: "session", action: "idle", sessionID, directory })
92
+ assert.equal(second.dispatched, true)
93
+ assert.equal(prompts.length, 2)
94
+ assert.equal(prompts[1].at, 6_000)
95
+ assert.equal(runtime.scheduledCount(), 1)
96
+
97
+ const secondStatusIdle = await runtime.onEvent({ kind: "session", action: "status", status: "idle", sessionID, directory })
98
+ assert.equal(secondStatusIdle.dispatched, false)
99
+ assert.equal(prompts.length, 2)
100
+
101
+ const idleTimer = await fireEarliestTimer()
102
+ assert.equal(idleTimer.dispatched, true, "timer may dispatch once the session is already known idle")
103
+ assert.equal(prompts.length, 3)
104
+ assert.equal(prompts[2].at, 11_000)
105
+ assert.equal(runtime.scheduledCount(), 0)
106
+
107
+ state = await readState(sessionID)
108
+ assert.equal(state.jobs[0].runCount, 3)
109
+ assert.equal(state.jobs[0].enabled, false)
110
+ assert.equal((await runtime.onEvent({ kind: "session", action: "idle", sessionID, directory })).dispatched, false)
111
+ assert.equal(prompts.length, 3)
112
+
113
+ const pauseSession = "ses_pause"
114
+ await runtime.onEvent({
115
+ kind: "command",
116
+ action: "executed",
117
+ name: "loop",
118
+ sessionID: pauseSession,
119
+ directory,
120
+ arguments: "4s --max-runs 2 pause work",
121
+ })
122
+ await runtime.onEvent({ kind: "session", action: "idle", sessionID: pauseSession, directory })
123
+ assert.equal(runtime.scheduledCount(), 1)
124
+ assert.equal(timers.size, 1)
125
+
126
+ const paused = await runtime.onEvent({ kind: "command", action: "executed", name: "loop-pause", sessionID: pauseSession, directory, arguments: "" })
127
+ assert.equal(paused.count, 1)
128
+ assert.equal(runtime.scheduledCount(), 0)
129
+ assert.equal(timers.size, 0)
130
+
131
+ const resumed = await runtime.onEvent({ kind: "command", action: "executed", name: "loop-resume", sessionID: pauseSession, directory, arguments: "" })
132
+ assert.equal(resumed.count, 1)
133
+ assert.equal((await readState(pauseSession)).jobs[0].lastRunAt, 0)
134
+ assert.equal(runtime.scheduledCount(), 0, "resume makes the job immediately due but still idle-gated")
135
+ const resumedRun = await runtime.onEvent({ kind: "session", action: "idle", sessionID: pauseSession, directory })
136
+ assert.equal(resumedRun.dispatched, true)
137
+ assert.equal((await readState(pauseSession)).jobs[0].enabled, false)
138
+
139
+ const cleanupSession = "ses_cleanup"
140
+ await runtime.onEvent({ kind: "command", action: "executed", name: "loop", sessionID: cleanupSession, directory, arguments: "10s cleanup work" })
141
+ await runtime.onEvent({ kind: "session", action: "idle", sessionID: cleanupSession, directory })
142
+ assert.equal(runtime.scheduledCount(), 1)
143
+ const stopped = await runtime.onEvent({ kind: "command", action: "executed", name: "loop-stop", sessionID: cleanupSession, directory, arguments: "all" })
144
+ assert.equal(stopped.count, 1)
145
+ assert.equal(runtime.scheduledCount(), 0)
146
+ assert.equal(timers.size, 0)
147
+
148
+ const deletedSession = "ses_deleted"
149
+ await runtime.onEvent({ kind: "command", action: "executed", name: "loop", sessionID: deletedSession, directory, arguments: "8s delete work" })
150
+ await runtime.onEvent({ kind: "session", action: "idle", sessionID: deletedSession, directory })
151
+ assert.equal(runtime.scheduledCount(), 1)
152
+ const deleted = await runtime.onEvent({ kind: "session", action: "deleted", sessionID: deletedSession, directory })
153
+ assert.equal(deleted.disposedScope, true)
154
+ assert.equal(runtime.scheduledCount(), 0)
155
+ assert.equal(timers.size, 0)
156
+
157
+ const disposeSession = "ses_dispose"
158
+ await runtime.onEvent({ kind: "command", action: "executed", name: "loop", sessionID: disposeSession, directory, arguments: "9s dispose work" })
159
+ await runtime.onEvent({ kind: "session", action: "idle", sessionID: disposeSession, directory })
160
+ assert.equal(runtime.scheduledCount(), 1)
161
+ assert.equal(await runtime.dispose(), true)
162
+ assert.equal(runtime.scheduledCount(), 0)
163
+ assert.equal(timers.size, 0)
164
+ assert.equal(await runtime.dispose(), false)
165
+ assert.deepEqual(errors, [])
166
+
167
+ console.log("OpenCode 2 idle-safe interval runtime tests passed")
168
+ } finally {
169
+ await runtime.dispose().catch(() => undefined)
170
+ await rm(directory, { recursive: true, force: true })
171
+ }
@@ -0,0 +1,267 @@
1
+ import assert from "node:assert/strict"
2
+ import { mkdtemp, readFile, rm } from "node:fs/promises"
3
+ import os from "node:os"
4
+ import path from "node:path"
5
+ import { createOpenCode2PromptRuntime } from "../src/source/opencode2/prompt-runtime.js"
6
+ import { formatOpenCode2LoopStatus } from "../src/source/opencode2/status.js"
7
+
8
+ const directory = await mkdtemp(path.join(os.tmpdir(), "opencode-loop-v2-prompt-"))
9
+ const sessionID = "ses_v2_prompt_runtime"
10
+ const prompts = []
11
+ const runtime = createOpenCode2PromptRuntime({ prompt: async (request) => { prompts.push(request) } })
12
+
13
+ async function readState() {
14
+ const file = path.join(directory, ".opencode", "opencode-loop", `${sessionID}.json`)
15
+ try {
16
+ return JSON.parse(await readFile(file, "utf8"))
17
+ } catch (error) {
18
+ if (error?.code === "ENOENT") return { jobs: [] }
19
+ throw error
20
+ }
21
+ }
22
+
23
+ try {
24
+ const added = await runtime.onEvent({ kind: "command", action: "executed", name: "loop", sessionID, directory, arguments: "0s --max-runs 2 --ask-never continue the current task" })
25
+ assert.equal(added.accepted, true)
26
+ let state = await readState()
27
+ assert.equal(state.jobs.length, 1)
28
+ assert.equal(state.jobs[0].runCount, 0)
29
+
30
+ assert.equal((await runtime.onEvent({ kind: "session", action: "idle", sessionID, directory })).dispatched, true)
31
+ assert.equal(prompts.length, 1)
32
+ assert.ok(prompts[0].text.includes("AUTONOMOUS OPENCODE LOOP ITERATION"))
33
+ assert.ok(prompts[0].text.includes("continue the current task"))
34
+ assert.ok(prompts[0].text.includes("Do not ask the user questions"))
35
+ state = await readState()
36
+ assert.equal(state.jobs[0].runCount, 1)
37
+ assert.equal(state.jobs[0].enabled, true)
38
+
39
+ const paused = await runtime.onEvent({ kind: "command", action: "executed", name: "loop-pause", sessionID, directory, arguments: "" })
40
+ assert.equal(paused.count, 1)
41
+ state = await readState()
42
+ assert.equal(state.jobs[0].paused, true)
43
+ assert.equal((await runtime.onEvent({ kind: "session", action: "idle", sessionID, directory })).dispatched, false)
44
+ assert.equal(prompts.length, 1)
45
+
46
+ const resumed = await runtime.onEvent({ kind: "command", action: "executed", name: "loop-resume", sessionID, directory, arguments: "" })
47
+ assert.equal(resumed.count, 1)
48
+ state = await readState()
49
+ assert.equal(state.jobs[0].paused, false)
50
+ assert.equal(state.jobs[0].lastRunAt, 0)
51
+
52
+ assert.equal((await runtime.onEvent({ kind: "session", action: "idle", sessionID, directory })).dispatched, true)
53
+ state = await readState()
54
+ assert.equal(state.jobs[0].runCount, 2)
55
+ assert.equal(state.jobs[0].enabled, false)
56
+
57
+ assert.equal((await runtime.onEvent({ kind: "session", action: "idle", sessionID, directory })).dispatched, false)
58
+ assert.equal(prompts.length, 2)
59
+
60
+ const status = await runtime.onEvent({ kind: "command", action: "executed", name: "loop-status", sessionID, directory, arguments: "" })
61
+ assert.equal(status.accepted, true)
62
+ assert.equal(prompts.length, 3)
63
+ assert.equal(prompts[2].noReply, true)
64
+ assert.match(prompts[2].text, /^OpenCode loop status:/)
65
+ assert.match(prompts[2].text, /runs=2/)
66
+ assert.match(prompts[2].text, /continue the current task/)
67
+
68
+ const interval = await runtime.onEvent({ kind: "command", action: "executed", name: "loop", sessionID, directory, arguments: "5m --name later --multi do this later" })
69
+ assert.equal(interval.accepted, true)
70
+ assert.equal(interval.job.intervalMs, 300_000)
71
+ assert.equal(runtime.scheduledCount(), 0)
72
+ assert.equal((await readState()).jobs.length, 2)
73
+
74
+ const intervalRemoved = await runtime.onEvent({ kind: "command", action: "executed", name: "loop-stop", sessionID, directory, arguments: "later" })
75
+ assert.equal(intervalRemoved.count, 1)
76
+ assert.equal((await readState()).jobs.length, 1)
77
+
78
+ const command = await runtime.onEvent({ kind: "command", action: "executed", name: "loop", sessionID, directory, arguments: "0s --command /review" })
79
+ assert.equal(command.accepted, false)
80
+ assert.ok(command.blockers.includes("command-capability"))
81
+ assert.equal((await readState()).jobs.length, 1)
82
+
83
+ const named = await runtime.onEvent({ kind: "command", action: "executed", name: "loop", sessionID, directory, arguments: "0s --name keep --multi keep working" })
84
+ assert.equal(named.accepted, true)
85
+ assert.equal((await readState()).jobs.length, 2)
86
+
87
+ const removed = await runtime.onEvent({ kind: "command", action: "executed", name: "loop-stop", sessionID, directory, arguments: "keep" })
88
+ assert.equal(removed.count, 1)
89
+ assert.equal((await readState()).jobs.length, 1)
90
+
91
+ const cleared = await runtime.onEvent({ kind: "command", action: "executed", name: "loop-clear", sessionID, directory, arguments: "missing-target-is-ignored" })
92
+ assert.equal(cleared.count, 1)
93
+ assert.equal(cleared.target, "all")
94
+ assert.equal((await readState()).jobs.length, 0)
95
+
96
+ const emptyStatus = await runtime.onEvent({ kind: "command", action: "executed", name: "loop-status", sessionID, directory, arguments: "" })
97
+ assert.equal(emptyStatus.accepted, true)
98
+ assert.equal(prompts.at(-1).noReply, true)
99
+ assert.equal(prompts.at(-1).text, "OpenCode loop status:\nNo active loop jobs.")
100
+
101
+ console.log("OpenCode 2 prompt runtime contract passed")
102
+ } finally {
103
+ await runtime.dispose()
104
+ await rm(directory, { recursive: true, force: true })
105
+ }
106
+
107
+ {
108
+ const current = Date.parse("2026-08-18T00:00:00.000Z")
109
+ const status = formatOpenCode2LoopStatus({
110
+ jobs: [{
111
+ id: "job_no_now",
112
+ name: "delayed",
113
+ action: "wait before the first run",
114
+ kind: "prompt",
115
+ intervalMs: 1_000,
116
+ immediate: false,
117
+ createdAt: new Date(current).toISOString(),
118
+ lastRunAt: 0,
119
+ runCount: 0,
120
+ failureCount: 0,
121
+ noOverlap: true,
122
+ }],
123
+ }, current)
124
+ assert.match(status.text, /due in 1s/)
125
+ }
126
+
127
+ {
128
+ const delayedDirectory = await mkdtemp(path.join(os.tmpdir(), "opencode-loop-v2-no-now-"))
129
+ const delayedSessionID = "ses_v2_no_now"
130
+ const delayedPrompts = []
131
+ const scheduled = []
132
+ let clock = Date.now()
133
+ const delayedRuntime = createOpenCode2PromptRuntime({
134
+ prompt: async (request) => { delayedPrompts.push(request) },
135
+ now: () => clock,
136
+ setTimer: (fn, ms) => {
137
+ const handle = { fn, ms, unref() {} }
138
+ scheduled.push(handle)
139
+ return handle
140
+ },
141
+ clearTimer: () => {},
142
+ })
143
+ const delayedFile = path.join(delayedDirectory, ".opencode", "opencode-loop", `${delayedSessionID}.json`)
144
+
145
+ try {
146
+ const added = await delayedRuntime.onEvent({
147
+ kind: "command",
148
+ action: "executed",
149
+ name: "loop",
150
+ sessionID: delayedSessionID,
151
+ directory: delayedDirectory,
152
+ arguments: "1s --no-now --max-runs 1 wait before the first run",
153
+ })
154
+ assert.equal(added.accepted, true)
155
+ assert.equal(added.job.immediate, false)
156
+
157
+ let state = JSON.parse(await readFile(delayedFile, "utf8"))
158
+ const createdAt = Date.parse(state.jobs[0].createdAt)
159
+ assert.ok(Number.isFinite(createdAt))
160
+ assert.ok(scheduled.some((timer) => timer.ms > 0), "--no-now must schedule a future first run")
161
+
162
+ clock = createdAt + 999
163
+ const earlyIdle = await delayedRuntime.onEvent({ kind: "session", action: "idle", sessionID: delayedSessionID, directory: delayedDirectory })
164
+ assert.equal(earlyIdle.dispatched, false)
165
+ assert.equal(delayedPrompts.length, 0, "--no-now must not dispatch before createdAt + interval")
166
+
167
+ clock = createdAt + 1_001
168
+ const dueIdle = await delayedRuntime.onEvent({ kind: "session", action: "idle", sessionID: delayedSessionID, directory: delayedDirectory })
169
+ assert.equal(dueIdle.dispatched, true)
170
+ assert.equal(delayedPrompts.length, 1)
171
+ state = JSON.parse(await readFile(delayedFile, "utf8"))
172
+ assert.equal(state.jobs[0].runCount, 1)
173
+ assert.equal(state.jobs[0].enabled, false)
174
+ } finally {
175
+ await delayedRuntime.dispose()
176
+ await rm(delayedDirectory, { recursive: true, force: true })
177
+ }
178
+ }
179
+
180
+ {
181
+ const nowDirectory = await mkdtemp(path.join(os.tmpdir(), "opencode-loop-v2-run-now-"))
182
+ const nowSessionID = "ses_v2_run_now"
183
+ const nowPrompts = []
184
+ const clock = Date.parse("2026-08-18T00:00:00.000Z")
185
+ const nowRuntime = createOpenCode2PromptRuntime({
186
+ prompt: async (request) => { nowPrompts.push(request) },
187
+ now: () => clock,
188
+ setTimer: () => ({ unref() {} }),
189
+ clearTimer: () => {},
190
+ })
191
+ const nowFile = path.join(nowDirectory, ".opencode", "opencode-loop", `${nowSessionID}.json`)
192
+
193
+ try {
194
+ await nowRuntime.onEvent({
195
+ kind: "command",
196
+ action: "executed",
197
+ name: "loop",
198
+ sessionID: nowSessionID,
199
+ directory: nowDirectory,
200
+ arguments: "0s --name first --multi --max-runs 1 first naturally due job",
201
+ })
202
+ await nowRuntime.onEvent({
203
+ kind: "command",
204
+ action: "executed",
205
+ name: "loop",
206
+ sessionID: nowSessionID,
207
+ directory: nowDirectory,
208
+ arguments: "10m --no-now --name second --multi --max-runs 1 second forced job",
209
+ })
210
+ const paused = await nowRuntime.onEvent({
211
+ kind: "command",
212
+ action: "executed",
213
+ name: "loop-pause",
214
+ sessionID: nowSessionID,
215
+ directory: nowDirectory,
216
+ arguments: "second",
217
+ })
218
+ assert.equal(paused.count, 1)
219
+
220
+ const missing = await nowRuntime.onEvent({
221
+ kind: "command",
222
+ action: "executed",
223
+ name: "loop-now",
224
+ sessionID: nowSessionID,
225
+ directory: nowDirectory,
226
+ arguments: "missing",
227
+ })
228
+ assert.equal(missing.count, 0)
229
+
230
+ const requested = await nowRuntime.onEvent({
231
+ kind: "command",
232
+ action: "executed",
233
+ name: "loop-now",
234
+ sessionID: nowSessionID,
235
+ directory: nowDirectory,
236
+ arguments: "second",
237
+ })
238
+ assert.equal(requested.count, 1)
239
+ assert.equal(requested.target, "second")
240
+
241
+ let state = JSON.parse(await readFile(nowFile, "utf8"))
242
+ const first = state.jobs.find((job) => job.name === "first")
243
+ const second = state.jobs.find((job) => job.name === "second")
244
+ assert.equal(first.runNowRequestedAt, undefined)
245
+ assert.equal(second.paused, false)
246
+ assert.equal(second.runNowRequestedAt, clock)
247
+ const status = formatOpenCode2LoopStatus(state, clock)
248
+ assert.match(status.text, /second forced job[^\n]*due in every idle[^\n]*run-now/)
249
+
250
+ const forcedIdle = await nowRuntime.onEvent({ kind: "session", action: "idle", sessionID: nowSessionID, directory: nowDirectory })
251
+ assert.equal(forcedIdle.dispatched, true)
252
+ assert.match(nowPrompts[0].text, /second forced job/)
253
+ state = JSON.parse(await readFile(nowFile, "utf8"))
254
+ assert.equal(state.jobs.find((job) => job.name === "second").runNowRequestedAt, undefined)
255
+ assert.equal(state.jobs.find((job) => job.name === "second").runCount, 1)
256
+ assert.equal(state.jobs.find((job) => job.name === "first").runCount, 0)
257
+
258
+ const naturalIdle = await nowRuntime.onEvent({ kind: "session", action: "idle", sessionID: nowSessionID, directory: nowDirectory })
259
+ assert.equal(naturalIdle.dispatched, true)
260
+ assert.match(nowPrompts[1].text, /first naturally due job/)
261
+ } finally {
262
+ await nowRuntime.dispose()
263
+ await rm(nowDirectory, { recursive: true, force: true })
264
+ }
265
+ }
266
+
267
+ console.log("OpenCode 2 --no-now and loop-now contracts passed")