@bybrawe/opencode-loop 0.5.10 → 0.5.11
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/CHANGELOG.md +1 -1
- package/README.md +8 -3
- package/package.json +2 -2
- package/src/index.js +151 -91
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.5.
|
|
3
|
+
## 0.5.11
|
|
4
4
|
|
|
5
5
|
- Added a watchdog loop in addition to one-shot due timers, so recurring jobs keep checking due work even if OpenCode misses an idle event or a due timeout gets effectively stuck behind a stale busy state.
|
|
6
6
|
- Added stronger stale-active-run recovery. If a plugin-injected run is still marked active after the recovery window, the scheduler treats it as finished, finalizes it, and continues without waiting for another manual command such as `/loop-status`.
|
package/README.md
CHANGED
|
@@ -9,9 +9,14 @@ It is useful for long coding sessions, `progress.md` workflows, TODO automation,
|
|
|
9
9
|
Repository: **ByBrawe/opencode-loop**
|
|
10
10
|
NPM package name: **@bybrawe/opencode-loop**
|
|
11
11
|
|
|
12
|
+
### Scheduler heartbeat note
|
|
13
|
+
|
|
14
|
+
v0.5.11 includes a referenced heartbeat scheduler. This is important in OpenCode TUI because local command hooks are event-driven: a normal `/loop-status` command can wake the plugin, but a timer-only loop should not depend on a user typing another command. The heartbeat periodically checks active loop jobs, clears stale completed runs, and starts due work only when the session appears idle.
|
|
15
|
+
|
|
16
|
+
|
|
12
17
|
## Current status
|
|
13
18
|
|
|
14
|
-
**v0.5.
|
|
19
|
+
**v0.5.11 is a reliability hotfix for recurring timers that only continued after manual activity.** It keeps the real due timer from v0.5.8 and the stale-busy recovery from v0.5.9, then adds a watchdog checker and stronger stale-active-run recovery. This targets the case where `/loop 1m ...` reaches its due time but does not fire until you run `/loop-status` or type another command. Scheduled `/compact` still routes through OpenCode's current TUI alias first, with fallback paths for older builds.
|
|
15
20
|
|
|
16
21
|
The known update-related symptoms from older builds are fixed:
|
|
17
22
|
|
|
@@ -27,7 +32,7 @@ The known update-related symptoms from older builds are fixed:
|
|
|
27
32
|
|
|
28
33
|
The TUI loop is still intentionally session-bound: it runs while OpenCode is open and the current session emits status/idle events. For long-running background work after closing the terminal or OpenCode, use `opencode-loopd`.
|
|
29
34
|
|
|
30
|
-
## v0.5.
|
|
35
|
+
## v0.5.11 quick behavior guide
|
|
31
36
|
|
|
32
37
|
OpenCode Loop has two triggers now:
|
|
33
38
|
|
|
@@ -74,7 +79,7 @@ Run a shell command every 10 minutes when idle.
|
|
|
74
79
|
|
|
75
80
|
Experimental persistent goal mode: keep working until the goal is complete, blocked, paused, cleared, or a safety limit is reached.
|
|
76
81
|
|
|
77
|
-
> Note: OpenCode custom command markdown still creates a tiny assistant turn for slash commands. v0.5.
|
|
82
|
+
> Note: OpenCode custom command markdown still creates a tiny assistant turn for slash commands. v0.5.11 keeps these command templates short and asks the model to reply `OK`. The actual loop scheduling is handled locally by the plugin. If that short turn briefly makes OpenCode look busy, the loop now recovers with fresh status checks, stale-active-run recovery, and a watchdog that does not require another manual command.
|
|
78
83
|
|
|
79
84
|
## Why this exists
|
|
80
85
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bybrawe/opencode-loop",
|
|
3
|
-
"version": "0.5.
|
|
4
|
-
"description": "Claude Code/Codex style /loop and experimental goal mode for OpenCode:
|
|
3
|
+
"version": "0.5.11",
|
|
4
|
+
"description": "Claude Code/Codex style /loop and experimental goal mode for OpenCode: heartbeat scheduler, idle-safe loops, scheduled commands, compact scheduling, verification, checkpoints, and persistent coding goals.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
7
7
|
"exports": {
|
package/src/index.js
CHANGED
|
@@ -4,13 +4,14 @@ import { spawn } from "node:child_process"
|
|
|
4
4
|
|
|
5
5
|
const SERVICE = "opencode-loop"
|
|
6
6
|
const STATE_DIR = ".opencode/opencode-loop"
|
|
7
|
-
const DEFAULT_ACTIVE_GUARD_MS =
|
|
8
|
-
const STALE_ACTIVE_RECOVERY_MS =
|
|
7
|
+
const DEFAULT_ACTIVE_GUARD_MS = 45_000
|
|
8
|
+
const STALE_ACTIVE_RECOVERY_MS = 45_000
|
|
9
9
|
const IDLE_DEBOUNCE_MS = 1_200
|
|
10
10
|
const BUSY_RETRY_MS = 5_000
|
|
11
11
|
const SESSION_STATUS_CACHE_MS = 1_500
|
|
12
12
|
const MIN_DUE_TIMER_MS = 250
|
|
13
13
|
const MAX_DUE_TIMER_MS = 2_147_000_000
|
|
14
|
+
const HEARTBEAT_MS = 2_500
|
|
14
15
|
const MAX_SCAN_FILES = 200
|
|
15
16
|
const MAX_SCAN_BYTES = 2_000_000
|
|
16
17
|
const GOAL_REPORT_DIR = "goals"
|
|
@@ -21,6 +22,9 @@ const handledCommands = new Map()
|
|
|
21
22
|
const idleTimers = new Map()
|
|
22
23
|
const dueTimers = new Map()
|
|
23
24
|
const watchdogTimers = new Map()
|
|
25
|
+
const runLocks = new Map()
|
|
26
|
+
const knownSessions = new Map()
|
|
27
|
+
let heartbeatTimer
|
|
24
28
|
const sessionStatuses = new Map()
|
|
25
29
|
const sessionStatusSeenAt = new Map()
|
|
26
30
|
|
|
@@ -444,7 +448,7 @@ async function say(client, sessionID, text) {
|
|
|
444
448
|
} catch {}
|
|
445
449
|
}
|
|
446
450
|
|
|
447
|
-
function commandKey(sessionID, name, args) { return `${sessionID || "no-session"}:${name || ""}:${args
|
|
451
|
+
function commandKey(sessionID, name, args) { return `${sessionID || "no-session"}:${name || ""}:${normalizeArgsForKey(args)}` }
|
|
448
452
|
function markHandled(sessionID, name, args) {
|
|
449
453
|
handledCommands.set(commandKey(sessionID, name, args), now())
|
|
450
454
|
for (const [key, time] of handledCommands.entries()) if (now() - time > 30_000) handledCommands.delete(key)
|
|
@@ -456,6 +460,41 @@ function wasHandled(sessionID, name, args) {
|
|
|
456
460
|
|
|
457
461
|
function commandName(name) { return String(name || "") }
|
|
458
462
|
function isPreset(name) { return ["loop-dev", "loop-testfix", "loop-compact", "loop-progress", "loop-safe-dev", "loop-command", "loop-cmd", "loop-prompt", "loop-ask", "loop-shell"].includes(name) }
|
|
463
|
+
|
|
464
|
+
function normalizeArgsForKey(args) {
|
|
465
|
+
if (args === undefined || args === null) return ""
|
|
466
|
+
if (typeof args === "string") return args.trim().replace(/\s+/g, " ")
|
|
467
|
+
if (Array.isArray(args)) return args.map(normalizeArgsForKey).join(" ").trim().replace(/\s+/g, " ")
|
|
468
|
+
try { return JSON.stringify(args) } catch { return String(args) }
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function rememberSession(directory, client, sessionID) {
|
|
472
|
+
if (!sessionID) return
|
|
473
|
+
knownSessions.set(sessionID, { directory, client, seenAt: now() })
|
|
474
|
+
startHeartbeat()
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function startHeartbeat() {
|
|
478
|
+
if (heartbeatTimer) return
|
|
479
|
+
heartbeatTimer = setInterval(() => {
|
|
480
|
+
for (const [sessionID, info] of [...knownSessions.entries()]) {
|
|
481
|
+
if (!info || now() - (info.seenAt || 0) > 12 * 60 * 60 * 1000) {
|
|
482
|
+
knownSessions.delete(sessionID)
|
|
483
|
+
continue
|
|
484
|
+
}
|
|
485
|
+
Promise.resolve()
|
|
486
|
+
.then(async () => {
|
|
487
|
+
await finalizeActiveRun(info.directory, info.client, sessionID, { forceStale: true })
|
|
488
|
+
await maybeRunDueJobs(info.directory, info.client, sessionID, { heartbeat: true })
|
|
489
|
+
})
|
|
490
|
+
.catch((error) => appendLoopLog(info.directory, "heartbeat-error", { sessionID, error: sdkErrorMessage(error) }).catch(() => {}))
|
|
491
|
+
}
|
|
492
|
+
if (!knownSessions.size && heartbeatTimer) {
|
|
493
|
+
clearInterval(heartbeatTimer)
|
|
494
|
+
heartbeatTimer = undefined
|
|
495
|
+
}
|
|
496
|
+
}, HEARTBEAT_MS)
|
|
497
|
+
}
|
|
459
498
|
function presetDefaults(name, args) {
|
|
460
499
|
const [maybeDuration, rest] = splitFirst(args)
|
|
461
500
|
const parsed = parseDuration(maybeDuration)
|
|
@@ -889,7 +928,9 @@ async function startWatchdog(directory, client, sessionID) {
|
|
|
889
928
|
})
|
|
890
929
|
.catch((error) => appendLoopLog(directory, "watchdog-error", { sessionID, error: sdkErrorMessage(error) }).catch(() => {}))
|
|
891
930
|
}, Math.max(1_000, BUSY_RETRY_MS))
|
|
892
|
-
|
|
931
|
+
// Keep this interval referenced. In current OpenCode TUI builds, plugin hooks
|
|
932
|
+
// are event-driven; a referenced watchdog helps scheduled loops wake up even
|
|
933
|
+
// when no manual /loop-status command is typed.
|
|
893
934
|
watchdogTimers.set(sessionID, timer)
|
|
894
935
|
}
|
|
895
936
|
|
|
@@ -1194,112 +1235,128 @@ ${prompt}` }] } })
|
|
|
1194
1235
|
}
|
|
1195
1236
|
|
|
1196
1237
|
async function maybeRunDueJobs(directory, client, sessionID, options = {}) {
|
|
1238
|
+
rememberSession(directory, client, sessionID)
|
|
1197
1239
|
const reschedule = async (minDelayMs = 0) => { await scheduleDueWork(directory, client, sessionID, minDelayMs) }
|
|
1198
1240
|
|
|
1199
|
-
if (
|
|
1200
|
-
if (options.force) await toast(client, "Loop queued: session is busy; it will run on the next idle check.", "info")
|
|
1201
|
-
await reschedule(BUSY_RETRY_MS)
|
|
1202
|
-
return
|
|
1203
|
-
}
|
|
1204
|
-
const active = activeRuns.get(sessionID)
|
|
1205
|
-
if (active && active.job?.noOverlap !== false && now() - active.startedAt < (active.job?.timeoutMs || DEFAULT_ACTIVE_GUARD_MS)) {
|
|
1241
|
+
if (runLocks.has(sessionID)) {
|
|
1206
1242
|
await reschedule(BUSY_RETRY_MS)
|
|
1207
1243
|
return
|
|
1208
1244
|
}
|
|
1245
|
+
runLocks.set(sessionID, now())
|
|
1246
|
+
let job
|
|
1247
|
+
try {
|
|
1248
|
+
await finalizeActiveRun(directory, client, sessionID, { forceStale: true })
|
|
1249
|
+
if (!await sessionIsIdle(client, sessionID, directory)) {
|
|
1250
|
+
if (options.force) await toast(client, "Loop queued: session is busy; it will run on the next idle check.", "info")
|
|
1251
|
+
await reschedule(BUSY_RETRY_MS)
|
|
1252
|
+
return
|
|
1253
|
+
}
|
|
1209
1254
|
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
return
|
|
1219
|
-
}
|
|
1220
|
-
let job = due[0]
|
|
1255
|
+
const active = activeRuns.get(sessionID)
|
|
1256
|
+
const activeAge = active ? now() - (active.startedAt || 0) : 0
|
|
1257
|
+
const activeGuard = active?.job?.timeoutMs || active?.job?.activeRecoveryMs || DEFAULT_ACTIVE_GUARD_MS
|
|
1258
|
+
if (active && active.job?.noOverlap !== false && activeAge < activeGuard) {
|
|
1259
|
+
await reschedule(BUSY_RETRY_MS)
|
|
1260
|
+
return
|
|
1261
|
+
}
|
|
1262
|
+
if (active && activeAge >= activeGuard) clearActiveRun(sessionID)
|
|
1221
1263
|
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
await writeState(directory, sessionID, state)
|
|
1234
|
-
await notifyJob(directory, job, "stop_file")
|
|
1235
|
-
await toast(client, "Loop stopped by --stop-file: " + job.stopFile, "success")
|
|
1236
|
-
await reschedule()
|
|
1237
|
-
return
|
|
1238
|
-
}
|
|
1239
|
-
if (await untilReached(directory, job)) {
|
|
1240
|
-
state.jobs = (state.jobs || []).filter((candidate) => candidate.id !== job.id)
|
|
1241
|
-
await writeState(directory, sessionID, state)
|
|
1242
|
-
await notifyJob(directory, job, "until_reached")
|
|
1243
|
-
await toast(client, `Loop stopped by --until: ${job.until}`, "success")
|
|
1244
|
-
await reschedule()
|
|
1245
|
-
return
|
|
1246
|
-
}
|
|
1264
|
+
const state = await readState(directory, sessionID)
|
|
1265
|
+
for (const candidate of state.jobs || []) {
|
|
1266
|
+
if (candidate.watchPaths?.length && !candidate.paused && candidate.enabled && await watchChanged(directory, candidate)) candidate.lastRunAt = 0
|
|
1267
|
+
}
|
|
1268
|
+
const due = dueJobs(state, options.force)
|
|
1269
|
+
if (!due.length) {
|
|
1270
|
+
await writeState(directory, sessionID, state)
|
|
1271
|
+
await reschedule()
|
|
1272
|
+
return
|
|
1273
|
+
}
|
|
1274
|
+
job = due[0]
|
|
1247
1275
|
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
job.paused = true
|
|
1276
|
+
if (job.maxRuntimeMs > 0 && now() - Date.parse(job.createdAt || new Date().toISOString()) >= job.maxRuntimeMs) {
|
|
1277
|
+
state.jobs = (state.jobs || []).filter((candidate) => candidate.id !== job.id)
|
|
1251
1278
|
await writeState(directory, sessionID, state)
|
|
1252
|
-
await notifyJob(directory, job, "
|
|
1253
|
-
await toast(client,
|
|
1279
|
+
await notifyJob(directory, job, "max_runtime_reached")
|
|
1280
|
+
await toast(client, `Loop stopped by --max-runtime: ${job.name || job.id}`, "success")
|
|
1281
|
+
await appendLoopLog(directory, "max-runtime", { sessionID, job: job.name || job.id })
|
|
1254
1282
|
await reschedule()
|
|
1255
1283
|
return
|
|
1256
1284
|
}
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
if (preflight.code !== 0) {
|
|
1260
|
-
job.paused = true
|
|
1261
|
-
job.failureCount = (job.failureCount || 0) + 1
|
|
1262
|
-
job.lastPreflightFailure = (job.preflightCommand + "\nexit=" + preflight.code + "\n" + preflight.stdout + "\n" + preflight.stderr).slice(0, 4000)
|
|
1263
|
-
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
|
|
1285
|
+
if (job.stopFile && await pathExists(path.resolve(directory, job.stopFile))) {
|
|
1286
|
+
state.jobs = (state.jobs || []).filter((candidate) => candidate.id !== job.id)
|
|
1264
1287
|
await writeState(directory, sessionID, state)
|
|
1265
|
-
await notifyJob(directory, job, "
|
|
1266
|
-
await toast(client, "
|
|
1288
|
+
await notifyJob(directory, job, "stop_file")
|
|
1289
|
+
await toast(client, "Loop stopped by --stop-file: " + job.stopFile, "success")
|
|
1267
1290
|
await reschedule()
|
|
1268
1291
|
return
|
|
1269
1292
|
}
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
job.runCount = (job.runCount || 0) + 1
|
|
1276
|
-
if (job.maxRuns > 0 && job.runCount >= job.maxRuns) {
|
|
1277
|
-
job.enabled = false
|
|
1278
|
-
await notifyJob(directory, job, "max_runs_reached")
|
|
1279
|
-
}
|
|
1280
|
-
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
|
|
1281
|
-
await writeState(directory, sessionID, state)
|
|
1282
|
-
await appendLoopLog(directory, "run", { sessionID, job: job.name || job.id, runCount: job.runCount })
|
|
1283
|
-
await toast(client, `Loop running: ${job.name || job.id}`, "info")
|
|
1284
|
-
|
|
1285
|
-
try {
|
|
1286
|
-
const result = await fireAction(directory, client, sessionID, job)
|
|
1287
|
-
if (!result.startsAssistantTurn) {
|
|
1288
|
-
const fresh = await readState(directory, sessionID)
|
|
1289
|
-
fresh.jobs = (fresh.jobs || []).filter((candidate) => candidate.enabled !== false || isGoalJob(candidate))
|
|
1290
|
-
await writeState(directory, sessionID, fresh)
|
|
1293
|
+
if (await untilReached(directory, job)) {
|
|
1294
|
+
state.jobs = (state.jobs || []).filter((candidate) => candidate.id !== job.id)
|
|
1295
|
+
await writeState(directory, sessionID, state)
|
|
1296
|
+
await notifyJob(directory, job, "until_reached")
|
|
1297
|
+
await toast(client, `Loop stopped by --until: ${job.until}`, "success")
|
|
1291
1298
|
await reschedule()
|
|
1292
1299
|
return
|
|
1293
1300
|
}
|
|
1294
|
-
|
|
1295
|
-
if (job.
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1301
|
+
|
|
1302
|
+
if (job.preflightCommand) {
|
|
1303
|
+
if (job.safe && dangerousShell(job.preflightCommand)) {
|
|
1304
|
+
job.paused = true
|
|
1305
|
+
await writeState(directory, sessionID, state)
|
|
1306
|
+
await notifyJob(directory, job, "preflight_blocked")
|
|
1307
|
+
await toast(client, "Preflight blocked in safe mode and loop paused: " + job.preflightCommand, "error")
|
|
1308
|
+
await reschedule()
|
|
1309
|
+
return
|
|
1310
|
+
}
|
|
1311
|
+
const preflight = await runShellCommand(job.preflightCommand, directory, job.timeoutMs || 300_000)
|
|
1312
|
+
await appendLoopLog(directory, "preflight", { sessionID, job: job.name || job.id, command: job.preflightCommand, code: preflight.code })
|
|
1313
|
+
if (preflight.code !== 0) {
|
|
1314
|
+
job.paused = true
|
|
1315
|
+
job.failureCount = (job.failureCount || 0) + 1
|
|
1316
|
+
job.lastPreflightFailure = (job.preflightCommand + "\nexit=" + preflight.code + "\n" + preflight.stdout + "\n" + preflight.stderr).slice(0, 4000)
|
|
1317
|
+
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
|
|
1318
|
+
await writeState(directory, sessionID, state)
|
|
1319
|
+
await notifyJob(directory, job, "preflight_failed")
|
|
1320
|
+
await toast(client, "Preflight failed and loop paused: " + job.preflightCommand, "warning")
|
|
1321
|
+
await reschedule()
|
|
1322
|
+
return
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
job = await ensureBranch(directory, job, client, sessionID)
|
|
1327
|
+
job = await maybeCompact(client, sessionID, job)
|
|
1328
|
+
job.lastRunAt = now()
|
|
1329
|
+
job.runCount = (job.runCount || 0) + 1
|
|
1330
|
+
if (job.maxRuns > 0 && job.runCount >= job.maxRuns) {
|
|
1331
|
+
job.enabled = false
|
|
1332
|
+
await notifyJob(directory, job, "max_runs_reached")
|
|
1333
|
+
}
|
|
1334
|
+
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
|
|
1335
|
+
await writeState(directory, sessionID, state)
|
|
1336
|
+
await appendLoopLog(directory, "run", { sessionID, job: job.name || job.id, runCount: job.runCount })
|
|
1337
|
+
await toast(client, `Loop running: ${job.name || job.id}`, "info")
|
|
1338
|
+
|
|
1339
|
+
try {
|
|
1340
|
+
const result = await fireAction(directory, client, sessionID, job)
|
|
1341
|
+
if (!result.startsAssistantTurn) {
|
|
1342
|
+
const fresh = await readState(directory, sessionID)
|
|
1343
|
+
fresh.jobs = (fresh.jobs || []).filter((candidate) => candidate.enabled !== false || isGoalJob(candidate))
|
|
1344
|
+
await writeState(directory, sessionID, fresh)
|
|
1345
|
+
await reschedule()
|
|
1346
|
+
return
|
|
1347
|
+
}
|
|
1348
|
+
let timer
|
|
1349
|
+
if (job.timeoutMs > 0) timer = setTimeout(() => { fireSdk(client, "session.abort", client.session.abort.bind(client.session), { sessionID }, { path: { id: sessionID }, body: {} }); toast(client, `Loop timeout fired: ${job.name || job.id}`, "warning").catch(() => {}) }, job.timeoutMs)
|
|
1350
|
+
activeRuns.set(sessionID, { jobId: job.id, job, startedAt: now(), timer })
|
|
1351
|
+
await reschedule(BUSY_RETRY_MS)
|
|
1352
|
+
} catch (error) {
|
|
1353
|
+
clearActiveRun(sessionID)
|
|
1354
|
+
await toast(client, `Loop job failed: ${error instanceof Error ? error.message : String(error)}`, "error")
|
|
1355
|
+
await appendLoopLog(directory, "error", { sessionID, job: job?.name || job?.id, error: error instanceof Error ? error.message : String(error) })
|
|
1356
|
+
await reschedule(BUSY_RETRY_MS)
|
|
1357
|
+
}
|
|
1358
|
+
} finally {
|
|
1359
|
+
runLocks.delete(sessionID)
|
|
1303
1360
|
}
|
|
1304
1361
|
}
|
|
1305
1362
|
|
|
@@ -1320,6 +1377,7 @@ async function addLoop(directory, client, sessionID, args, defaults = {}) {
|
|
|
1320
1377
|
const parsed = parseLoopArgs(args, defaults)
|
|
1321
1378
|
if (!parsed.ok) { await toast(client, parsed.error, "warning"); return }
|
|
1322
1379
|
if (parsed.job.watchPaths.length) parsed.job.watchSnapshot = await snapshotPaths(directory, parsed.job.watchPaths)
|
|
1380
|
+
if (!parsed.job.activeRecoveryMs) parsed.job.activeRecoveryMs = Math.max(20_000, Math.min(90_000, (parsed.job.intervalMs || 0) + 10_000))
|
|
1323
1381
|
if (parsed.job.dryRun) { await toast(client, `Loop dry run: ${jobLabel(parsed.job)}`, "info"); await say(client, sessionID, "OpenCode loop dry run:\n```json\n" + JSON.stringify(parsed.job, null, 2) + "\n```"); return }
|
|
1324
1382
|
const state = await readState(directory, sessionID)
|
|
1325
1383
|
const jobs = Array.isArray(state.jobs) ? state.jobs : []
|
|
@@ -1532,6 +1590,7 @@ async function handleCommand(directory, client, input, fallbackName, fallbackArg
|
|
|
1532
1590
|
const sessionID = input?.sessionID
|
|
1533
1591
|
const args = input?.arguments ?? fallbackArgs ?? ""
|
|
1534
1592
|
if (!sessionID || !name) return false
|
|
1593
|
+
rememberSession(directory, client, sessionID)
|
|
1535
1594
|
if (wasHandled(sessionID, name, args)) return true
|
|
1536
1595
|
markHandled(sessionID, name, args)
|
|
1537
1596
|
|
|
@@ -1616,6 +1675,7 @@ export const OpenCodeLoopPlugin = async ({ client, directory }) => {
|
|
|
1616
1675
|
await handleCommand(directory, client, props, props.name, props.arguments)
|
|
1617
1676
|
}
|
|
1618
1677
|
const statusUpdate = updateSessionStatusFromEvent(event)
|
|
1678
|
+
if (statusUpdate?.sessionID) rememberSession(directory, client, statusUpdate.sessionID)
|
|
1619
1679
|
if (statusUpdate?.idle) scheduleIdleWork(directory, client, statusUpdate.sessionID)
|
|
1620
1680
|
},
|
|
1621
1681
|
}
|