@bybrawe/opencode-loop 0.5.23 → 0.5.25

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/src/index.js CHANGED
@@ -40,6 +40,7 @@ const sessionParents = new Map()
40
40
  let heartbeatTimer
41
41
  const sessionStatuses = new Map()
42
42
  const sessionStatusSeenAt = new Map()
43
+ const loopCompactionRequests = new Map()
43
44
 
44
45
  const DEFAULT_PROGRESS_MD = `# Progress
45
46
 
@@ -532,11 +533,72 @@ function compactTuiCommandName(command = "compact") {
532
533
  return undefined
533
534
  }
534
535
 
535
- async function compactSession(client, sessionID) {
536
- // OpenCode's TUI API accepts legacy keybind aliases (session_compact) in
537
- // current builds, while some older docs/examples mention the event value
538
- // (session.compact). Try the alias first, then the event value, then the
539
- // session summarize endpoint as a last resort.
536
+ async function readRecentSessionMessages(client, sessionID, directory, limit = 20) {
537
+ if (!client?.session?.messages) return undefined
538
+ const query = { limit }
539
+ if (directory) query.directory = directory
540
+ try {
541
+ const messages = await sdkCall(
542
+ client.session.messages.bind(client.session),
543
+ { path: { id: sessionID }, query },
544
+ { path: { sessionID }, query },
545
+ { sessionID, ...query },
546
+ )
547
+ return Array.isArray(messages) ? messages : undefined
548
+ } catch {
549
+ return undefined
550
+ }
551
+ }
552
+
553
+ function orderedSessionMessages(messages) {
554
+ return (messages || [])
555
+ .map((message, index) => {
556
+ const info = message?.info || message || {}
557
+ const created = Number(info?.time?.created || 0)
558
+ return { message, index, created: Number.isFinite(created) ? created : 0 }
559
+ })
560
+ .sort((a, b) => a.created - b.created || a.index - b.index)
561
+ .map((entry) => entry.message)
562
+ }
563
+
564
+ async function activeRunCompletionFromMessages(directory, client, sessionID, active) {
565
+ const messages = await readRecentSessionMessages(client, sessionID, directory)
566
+ if (!messages) return "unknown"
567
+ const tail = orderedSessionMessages(messages).at(-1)
568
+ const info = tail?.info || tail
569
+ if (!info || info.role !== "assistant") return "incomplete"
570
+ const completed = Number(info?.time?.completed || 0)
571
+ const created = Number(info?.time?.created || 0)
572
+ if (!Number.isFinite(completed) || completed <= 0) return "incomplete"
573
+ const startedAt = Number(active?.startedAt || 0)
574
+ if (startedAt > 0 && completed < startedAt && (!Number.isFinite(created) || created < startedAt)) return "incomplete"
575
+ return "completed"
576
+ }
577
+
578
+ async function resolveCompactionModel(directory, client, sessionID, preferredModel) {
579
+ const preferred = normalizedModelRef(preferredModel)
580
+ if (preferred) return preferred
581
+ const cached = normalizedModelRef(sessionExecutionContexts.get(sessionID)?.model)
582
+ if (cached) return cached
583
+ const captured = await captureSessionExecutionContext(client, sessionID)
584
+ const capturedModel = normalizedModelRef(captured?.model)
585
+ if (capturedModel) return capturedModel
586
+ const messages = await readRecentSessionMessages(client, sessionID, directory)
587
+ for (const message of orderedSessionMessages(messages).reverse()) {
588
+ const info = message?.info || message
589
+ const model = normalizedModelRef(info?.model) || normalizedModelRef(info)
590
+ if (!model) continue
591
+ const previous = sessionExecutionContexts.get(sessionID) || {}
592
+ sessionExecutionContexts.set(sessionID, { ...previous, model })
593
+ return model
594
+ }
595
+ return undefined
596
+ }
597
+
598
+ async function compactSession(directory, client, sessionID, preferredModel) {
599
+ // Prefer the native TUI command when a TUI is present. Headless/server hosts
600
+ // fall back to session.summarize, whose current API requires an explicit
601
+ // provider/model pair.
540
602
  for (const command of ["session.compact", "session_compact"]) {
541
603
  try {
542
604
  await executeTuiCommand(client, command)
@@ -546,17 +608,21 @@ async function compactSession(client, sessionID) {
546
608
  }
547
609
  }
548
610
  try {
611
+ if (!client?.session?.summarize) throw new Error("client.session.summarize is not available")
612
+ const model = await resolveCompactionModel(directory, client, sessionID, preferredModel)
613
+ if (!model) throw new Error("could not resolve a provider/model for session.summarize")
614
+ const body = { providerID: model.providerID, modelID: model.modelID, auto: false }
549
615
  await sdkCall(
550
616
  client.session.summarize.bind(client.session),
551
- { path: { id: sessionID }, body: {} },
552
- { path: { sessionID }, body: {} },
553
- { sessionID },
617
+ { path: { id: sessionID }, body },
618
+ { path: { sessionID }, body },
619
+ { sessionID, ...body },
554
620
  )
555
621
  return true
556
622
  } catch (error) {
557
623
  await log(client, "warn", "session.summarize fallback failed", { error: sdkErrorMessage(error) })
558
624
  }
559
- await toast(client, "Could not run /compact from loop. Check OpenCode version and active TUI session.", "error")
625
+ await toast(client, "Could not run /compact from loop. Check OpenCode version and active session model.", "error")
560
626
  return false
561
627
  }
562
628
 
@@ -850,6 +916,7 @@ function disposeRuntime(directory, client) {
850
916
  sessionStatuses.delete(sessionID)
851
917
  sessionStatusSeenAt.delete(sessionID)
852
918
  sessionExecutionContexts.delete(sessionID)
919
+ loopCompactionRequests.delete(sessionID)
853
920
  for (const key of handledCommands.keys()) if (key.startsWith(`${sessionID}:`)) handledCommands.delete(key)
854
921
  for (const key of handledCommandEvents.keys()) if (key.startsWith(`${sessionID}:`)) handledCommandEvents.delete(key)
855
922
  }
@@ -1079,15 +1146,18 @@ async function ensureBranch(directory, job, client, sessionID) {
1079
1146
  return job
1080
1147
  }
1081
1148
 
1082
- async function maybeCompact(client, sessionID, job) {
1149
+ async function maybeCompact(directory, client, sessionID, job) {
1083
1150
  const dueRuns = job.compactEveryRuns > 0 && (job.runCount || 0) > 0 && (job.runCount || 0) % job.compactEveryRuns === 0 && job.lastCompactRunCount !== job.runCount
1084
1151
  const dueTime = job.compactEveryMs > 0 && (!job.lastCompactAt || now() - job.lastCompactAt >= job.compactEveryMs)
1085
- if (!dueRuns && !dueTime) return job
1086
- if (await compactSession(client, sessionID)) {
1152
+ if (!dueRuns && !dueTime) return { job, started: false }
1153
+ beginLoopCompaction(sessionID, job.id, true)
1154
+ if (await compactSession(directory, client, sessionID, job.model)) {
1087
1155
  job.lastCompactAt = now()
1088
1156
  job.lastCompactRunCount = job.runCount || 0
1157
+ return { job, started: true }
1089
1158
  }
1090
- return job
1159
+ loopCompactionRequests.delete(sessionID)
1160
+ return { job, started: false }
1091
1161
  }
1092
1162
 
1093
1163
  async function snapshotPaths(directory, files) {
@@ -1226,12 +1296,21 @@ function staleActiveRun(sessionID) {
1226
1296
  async function canFinalizeActiveRun(directory, client, sessionID, active, options = {}) {
1227
1297
  if (hasActiveToolCalls(sessionID) || hasBusyDescendant(sessionID)) return false
1228
1298
  if (!options.requireIdle && !options.forceStale) return true
1229
- if (options.forceStale && staleActiveRun(sessionID)) return true
1230
- if (!options.requireIdle) return false
1299
+
1300
+ const completion = options.forceStale
1301
+ ? await activeRunCompletionFromMessages(directory, client, sessionID, active)
1302
+ : undefined
1303
+ if (completion === "completed") return true
1304
+ if (!options.requireIdle) return completion === "unknown" && staleActiveRun(sessionID)
1231
1305
 
1232
1306
  const live = await readLiveSessionStatus(client, sessionID, directory)
1233
- if (live?.type) return live.type === "idle"
1307
+ if (live?.type === "idle") return true
1308
+ if (live?.type) {
1309
+ if ((live.type === "busy" || live.type === "retry") && options.forceStale && completion === "unknown" && staleActiveRun(sessionID)) return true
1310
+ return false
1311
+ }
1234
1312
 
1313
+ if (options.forceStale && completion === "unknown" && staleActiveRun(sessionID)) return true
1235
1314
  const cached = sessionStatuses.get(sessionID)
1236
1315
  const seenAt = sessionStatusSeenAt.get(sessionID) || 0
1237
1316
  return cached === "idle" && seenAt > (active.startedAt || 0)
@@ -1299,10 +1378,21 @@ async function sessionStatusType(client, sessionID, directory, options = {}) {
1299
1378
  // plugin-injected turn until the next user command touches the session.
1300
1379
  // When the only reason we still think the session is busy is our own stale
1301
1380
  // active-run guard, recover instead of waiting for another manual command.
1302
- if ((live.type === "busy" || live.type === "retry") && options.recoverStaleActive !== false && staleActiveRun(sessionID)) {
1303
- sessionStatuses.set(sessionID, "idle")
1304
- sessionStatusSeenAt.set(sessionID, now())
1305
- return "idle"
1381
+ if ((live.type === "busy" || live.type === "retry") && options.recoverStaleActive !== false) {
1382
+ const active = activeRuns.get(sessionID)
1383
+ if (active) {
1384
+ const completion = await activeRunCompletionFromMessages(directory, client, sessionID, active)
1385
+ if (completion === "completed" || (completion === "unknown" && staleActiveRun(sessionID))) {
1386
+ sessionStatuses.set(sessionID, "idle")
1387
+ sessionStatusSeenAt.set(sessionID, now())
1388
+ await appendLoopLog(directory, completion === "completed" ? "status-message-complete-recovery" : "status-stale-recovery", {
1389
+ sessionID,
1390
+ job: active.job?.name || active.jobId,
1391
+ startedAt: active.startedAt,
1392
+ })
1393
+ return "idle"
1394
+ }
1395
+ }
1306
1396
  }
1307
1397
  sessionStatuses.set(sessionID, live.type)
1308
1398
  sessionStatusSeenAt.set(sessionID, now())
@@ -1448,9 +1538,53 @@ function dueJobs(state, force = false) {
1448
1538
  function clearActiveRun(sessionID) {
1449
1539
  const active = activeRuns.get(sessionID)
1450
1540
  if (active?.timer) clearTimeout(active.timer)
1541
+ const compact = loopCompactionRequests.get(sessionID)
1542
+ if (!compact || !active || compact.jobId === active.jobId) loopCompactionRequests.delete(sessionID)
1451
1543
  activeRuns.delete(sessionID)
1452
1544
  }
1453
1545
 
1546
+ function beginLoopCompaction(sessionID, jobId, resumeAfter = false) {
1547
+ loopCompactionRequests.set(sessionID, {
1548
+ jobId,
1549
+ resumeAfter,
1550
+ requestedAt: now(),
1551
+ startedAt: 0,
1552
+ completedAt: 0,
1553
+ })
1554
+ }
1555
+
1556
+ async function noteLoopCompactionStarted(directory, sessionID) {
1557
+ const pending = loopCompactionRequests.get(sessionID)
1558
+ if (!pending) return false
1559
+ if (!pending.startedAt) {
1560
+ pending.startedAt = now()
1561
+ loopCompactionRequests.set(sessionID, pending)
1562
+ await appendLoopLog(directory, "compact-started", { sessionID, job: pending.jobId, resumeAfter: pending.resumeAfter })
1563
+ }
1564
+ return true
1565
+ }
1566
+
1567
+ async function finalizeLoopCompaction(directory, client, sessionID) {
1568
+ const pending = loopCompactionRequests.get(sessionID)
1569
+ const active = activeRuns.get(sessionID)
1570
+ if (!pending || !active || pending.jobId !== active.jobId) return false
1571
+ return await finalizeActiveRun(directory, client, sessionID)
1572
+ }
1573
+
1574
+ async function noteLoopCompactionCompleted(directory, client, sessionID) {
1575
+ const pending = loopCompactionRequests.get(sessionID)
1576
+ if (!pending) return false
1577
+ pending.completedAt = now()
1578
+ loopCompactionRequests.set(sessionID, pending)
1579
+ await appendLoopLog(directory, "compact-event", { sessionID, job: pending.jobId, resumeAfter: pending.resumeAfter })
1580
+ const timer = setTimeout(() => {
1581
+ finalizeLoopCompaction(directory, client, sessionID)
1582
+ .catch((error) => log(client, "error", "compaction finalization failed", { error: sdkErrorMessage(error) }))
1583
+ }, 0)
1584
+ timer.unref?.()
1585
+ return true
1586
+ }
1587
+
1454
1588
  async function recoverActiveDispatchFailure(directory, client, sessionID, jobId, runToken, error) {
1455
1589
  const active = activeRuns.get(sessionID)
1456
1590
  if (!active || active.jobId !== jobId || active.runToken !== runToken) return false
@@ -1718,6 +1852,20 @@ async function finalizeActiveRun(directory, client, sessionID, options = {}) {
1718
1852
  if (!active) return
1719
1853
  if (!await canFinalizeActiveRun(directory, client, sessionID, active, options)) return false
1720
1854
  const recoveredStale = staleActiveRun(sessionID)
1855
+ if (active.compactionOnly) {
1856
+ const pending = loopCompactionRequests.get(sessionID)
1857
+ clearActiveRun(sessionID)
1858
+ sessionStatuses.delete(sessionID)
1859
+ sessionStatusSeenAt.delete(sessionID)
1860
+ await appendLoopLog(directory, pending?.completedAt ? "compact-finished" : "compact-idle-fallback", {
1861
+ sessionID,
1862
+ job: active.job?.name || active.jobId,
1863
+ startedAt: active.startedAt,
1864
+ nativeEvent: Boolean(pending?.completedAt),
1865
+ })
1866
+ await scheduleDueWork(directory, client, sessionID)
1867
+ return true
1868
+ }
1721
1869
  clearActiveRun(sessionID)
1722
1870
  const state = await readState(directory, sessionID)
1723
1871
  let job = (state.jobs || []).find((candidate) => candidate.id === active.jobId)
@@ -1781,8 +1929,10 @@ async function fireAction(directory, client, sessionID, job) {
1781
1929
  const agent = job.agent || "build"
1782
1930
  const model = normalizedModelRef(job.model)
1783
1931
  if (kind === "compact") {
1784
- const ok = await compactSession(client, sessionID)
1785
- return { startsAssistantTurn: ok, pause: !ok, reason: "compact_failed" }
1932
+ beginLoopCompaction(sessionID, job.id, false)
1933
+ const ok = await compactSession(directory, client, sessionID, model)
1934
+ if (!ok) loopCompactionRequests.delete(sessionID)
1935
+ return { startsAssistantTurn: ok, pause: !ok, reason: "compact_failed", compaction: ok }
1786
1936
  }
1787
1937
  if (kind === "command") {
1788
1938
  const normalized = action.startsWith("/") ? action.slice(1) : action
@@ -1794,8 +1944,10 @@ async function fireAction(directory, client, sessionID, job) {
1794
1944
  const tuiCommand = compactTuiCommandName(command)
1795
1945
  if (tuiCommand) {
1796
1946
  guardLoopOwnedUserMessage(sessionID)
1797
- await compactSession(client, sessionID)
1798
- return { startsAssistantTurn: true }
1947
+ beginLoopCompaction(sessionID, job.id, false)
1948
+ const ok = await compactSession(directory, client, sessionID, model)
1949
+ if (!ok) loopCompactionRequests.delete(sessionID)
1950
+ return { startsAssistantTurn: ok, pause: !ok, reason: "compact_failed", compaction: ok }
1799
1951
  }
1800
1952
  guardLoopOwnedUserMessage(sessionID)
1801
1953
  const commandBody = { command, arguments: argumentsText, agent }
@@ -1939,7 +2091,25 @@ async function maybeRunDueJobs(directory, client, sessionID, options = {}) {
1939
2091
  }
1940
2092
 
1941
2093
  job = await ensureBranch(directory, job, client, sessionID)
1942
- job = await maybeCompact(client, sessionID, job)
2094
+ const compactResult = await maybeCompact(directory, client, sessionID, job)
2095
+ job = compactResult.job
2096
+ if (compactResult.started) {
2097
+ state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
2098
+ await writeState(directory, sessionID, state)
2099
+ let timer
2100
+ if (job.timeoutMs > 0) timer = setTimeout(() => { fireSdk(client, "session.abort", client.session.abort.bind(client.session), { path: { id: sessionID }, body: {} }, { path: { sessionID }, body: {} }, { sessionID }); toast(client, `Loop compact timeout fired: ${job.name || job.id}`, "warning").catch(() => {}) }, job.timeoutMs)
2101
+ const runToken = `${job.id}:compact:${now().toString(36)}:${Math.random().toString(16).slice(2)}`
2102
+ activeRuns.set(sessionID, { jobId: job.id, job, startedAt: now(), timer, runToken, compactionOnly: true })
2103
+ const pending = loopCompactionRequests.get(sessionID)
2104
+ if (pending?.jobId === job.id && pending.completedAt) {
2105
+ await finalizeLoopCompaction(directory, client, sessionID)
2106
+ return
2107
+ }
2108
+ sessionStatuses.set(sessionID, "busy")
2109
+ sessionStatusSeenAt.set(sessionID, now())
2110
+ await reschedule(BUSY_RETRY_MS)
2111
+ return
2112
+ }
1943
2113
  job.watchTriggered = false
1944
2114
  job.lastRunAt = now()
1945
2115
  job.runCount = (job.runCount || 0) + 1
@@ -1972,7 +2142,14 @@ async function maybeRunDueJobs(directory, client, sessionID, options = {}) {
1972
2142
  let timer
1973
2143
  if (job.timeoutMs > 0) timer = setTimeout(() => { fireSdk(client, "session.abort", client.session.abort.bind(client.session), { path: { id: sessionID }, body: {} }, { path: { sessionID }, body: {} }, { sessionID }); toast(client, `Loop timeout fired: ${job.name || job.id}`, "warning").catch(() => {}) }, job.timeoutMs)
1974
2144
  const runToken = `${job.id}:${now().toString(36)}:${Math.random().toString(16).slice(2)}`
1975
- activeRuns.set(sessionID, { jobId: job.id, job, startedAt: now(), timer, runToken })
2145
+ activeRuns.set(sessionID, { jobId: job.id, job, startedAt: now(), timer, runToken, compactionAction: result.compaction === true })
2146
+ if (result.compaction) {
2147
+ const pending = loopCompactionRequests.get(sessionID)
2148
+ if (pending?.jobId === job.id && pending.completedAt) {
2149
+ await finalizeLoopCompaction(directory, client, sessionID)
2150
+ return
2151
+ }
2152
+ }
1976
2153
  if (result.dispatch) {
1977
2154
  void result.dispatch.catch((error) => {
1978
2155
  recoverActiveDispatchFailure(directory, client, sessionID, job.id, runToken, error)
@@ -2344,7 +2521,9 @@ export const OpenCodeLoopPlugin = async ({ client, directory }) => {
2344
2521
  "command.execute.before": async (input, output) => { await handleCommand(directory, client, input, undefined, undefined, output) },
2345
2522
  "tool.execute.before": async (input) => { markToolCallActive(input) },
2346
2523
  "tool.execute.after": async (input) => { markToolCallFinished(input) },
2524
+ "experimental.session.compacting": async (input) => { await noteLoopCompactionStarted(directory, input?.sessionID) },
2347
2525
  event: async ({ event }) => {
2526
+ if (event.type === "session.compacted") await noteLoopCompactionCompleted(directory, client, event?.properties?.sessionID)
2348
2527
  updateSessionRelationshipFromEvent(event)
2349
2528
  if (event.type === "message.updated") updateSessionExecutionContext(event?.properties?.info)
2350
2529
  updateToolActivityFromEvent(event)