@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # Changelog
2
2
 
3
- ## 0.5.10
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.10 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.
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.10 quick behavior guide
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.10 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.
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.10",
4
- "description": "Claude Code/Codex style /loop and experimental goal mode for OpenCode: watchdog timers, stale-busy recovery, idle-safe loops, scheduled commands, compact scheduling, verification, checkpoints, and persistent coding goals.",
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 = 60_000
8
- const STALE_ACTIVE_RECOVERY_MS = 60_000
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
- if (typeof timer.unref === "function") timer.unref()
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 (!await sessionIsIdle(client, sessionID, directory)) {
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
- const state = await readState(directory, sessionID)
1211
- for (const job of state.jobs || []) {
1212
- if (job.watchPaths?.length && !job.paused && job.enabled && await watchChanged(directory, job)) job.lastRunAt = 0
1213
- }
1214
- let due = dueJobs(state, options.force)
1215
- if (!due.length) {
1216
- await writeState(directory, sessionID, state)
1217
- await reschedule()
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
- if (job.maxRuntimeMs > 0 && now() - Date.parse(job.createdAt || new Date().toISOString()) >= job.maxRuntimeMs) {
1223
- state.jobs = (state.jobs || []).filter((candidate) => candidate.id !== job.id)
1224
- await writeState(directory, sessionID, state)
1225
- await notifyJob(directory, job, "max_runtime_reached")
1226
- await toast(client, `Loop stopped by --max-runtime: ${job.name || job.id}`, "success")
1227
- await appendLoopLog(directory, "max-runtime", { sessionID, job: job.name || job.id })
1228
- await reschedule()
1229
- return
1230
- }
1231
- if (job.stopFile && await pathExists(path.resolve(directory, job.stopFile))) {
1232
- state.jobs = (state.jobs || []).filter((candidate) => candidate.id !== job.id)
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
- if (job.preflightCommand) {
1249
- if (job.safe && dangerousShell(job.preflightCommand)) {
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, "preflight_blocked")
1253
- await toast(client, "Preflight blocked in safe mode and loop paused: " + job.preflightCommand, "error")
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
- const preflight = await runShellCommand(job.preflightCommand, directory, job.timeoutMs || 300_000)
1258
- await appendLoopLog(directory, "preflight", { sessionID, job: job.name || job.id, command: job.preflightCommand, code: preflight.code })
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, "preflight_failed")
1266
- await toast(client, "Preflight failed and loop paused: " + job.preflightCommand, "warning")
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
- job = await ensureBranch(directory, job, client, sessionID)
1273
- job = await maybeCompact(client, sessionID, job)
1274
- job.lastRunAt = now()
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
- let timer
1295
- 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)
1296
- activeRuns.set(sessionID, { jobId: job.id, job, startedAt: now(), timer })
1297
- await reschedule(BUSY_RETRY_MS)
1298
- } catch (error) {
1299
- clearActiveRun(sessionID)
1300
- await toast(client, `Loop job failed: ${error instanceof Error ? error.message : String(error)}`, "error")
1301
- await appendLoopLog(directory, "error", { sessionID, job: job.name || job.id, error: error instanceof Error ? error.message : String(error) })
1302
- await reschedule(BUSY_RETRY_MS)
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
  }