(directory, client, sessionID, args, defaults = {})
| 1374 | } |
| 1375 | |
| 1376 | function normalizeActionForCompare(value) { |
| 1377 | return String(value || "").replace(/\s+/g, " ").trim() |
| 1378 | } |
| 1379 | |
| 1380 | function sameLoopDefinition(a, b) { |
| 1381 | if (!a || !b) return false |
| 1382 | return (a.name || "") === (b.name || "") && |
| 1383 | Number(a.intervalMs || 0) === Number(b.intervalMs || 0) && |
| 1384 | normalizeActionForCompare(a.action) === normalizeActionForCompare(b.action) && |
| 1385 | normalizeActionForCompare(a.kind) === normalizeActionForCompare(b.kind) && |
| 1386 | normalizeActionForCompare(a.promptFile) === normalizeActionForCompare(b.promptFile) |
| 1387 | } |
| 1388 | |
| 1389 | async function addLoop(directory, client, sessionID, args, defaults = {}) { |
| 1390 | const parsed = parseLoopArgs(args, defaults) |
| 1391 | if (!parsed.ok) { await toast(client, parsed.error, "warning"); return } |
| 1392 | if (parsed.job.watchPaths.length) parsed.job.watchSnapshot = await snapshotPaths(directory, parsed.job.watchPaths) |
| 1393 | if (!parsed.job.activeRecoveryMs) parsed.job.activeRecoveryMs = Math.max(20_000, Math.min(90_000, (parsed.job.intervalMs || 0) + 10_000)) |
| 1394 | 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 } |
| 1395 | const state = await readState(directory, sessionID) |
| 1396 | const jobs = Array.isArray(state.jobs) ? state.jobs : [] |
| 1397 | |
| 1398 | // Default behavior is replace/upsert, not append forever. This prevents duplicate |
| 1399 | // loops when OpenCode emits both command.execute.before and command.executed, |
| 1400 | // and it matches the common expectation that /loop configures the current loop. |
| 1401 | let replaced = false |
| 1402 | if (!parsed.job.multi) { |
| 1403 | const targetName = parsed.job.name || "default" |
| 1404 | parsed.job.name = targetName |
| 1405 | state.jobs = jobs.filter((existing) => { |
| 1406 | const existingName = existing.name || "default" |
| 1407 | const shouldReplace = existingName === targetName || sameLoopDefinition(existing, parsed.job) |
| 1408 | if (shouldReplace) replaced = true |
| 1409 | return !shouldReplace |
| 1410 | }) |
| 1411 | } else { |
no test coverage detected