@bybrawe/opencode-loop 0.5.23 → 0.5.24

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,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.24
4
+
5
+ - Cross-check stale OpenCode `busy`/`retry` status against the chronological session tail and only recover early when the latest assistant message has a real `time.completed`; an explicitly unfinished assistant tail is never force-finalized.
6
+ - Track scheduled compaction through OpenCode's native `experimental.session.compacting` hook and `session.compacted` event, while retaining idle/status fallbacks for older hosts.
7
+ - Serialize `--compact-every` as its own compaction phase so the next loop prompt/shell action cannot overlap an in-progress compaction; older hosts that only report idle also finalize this phase without running normal verify/postrun hooks.
8
+ - Fix headless/server compact fallback for current OpenCode by supplying the required `providerID`, `modelID`, and `auto: false` payload to `session.summarize`.
9
+ - Add deterministic regressions for completed-vs-running assistant tails, native compaction completion, compact/action serialization, and current summarize payloads.
10
+
3
11
  ## 0.5.23
4
12
 
5
13
  - Track non-blocking `session.prompt` and `session.shell` dispatch promises instead of treating every fire-and-forget request as successfully started.
package/README.md CHANGED
@@ -16,7 +16,7 @@ v0.5.11 includes a referenced heartbeat scheduler. This is important in OpenCode
16
16
 
17
17
  ## Current status
18
18
 
19
- **v0.5.23 adds immediate, token-safe recovery when a scheduler prompt/shell dispatch is rejected, without automatically replaying the prompt.** **v0.5.22 adds forward-compatible OpenCode command handling plus Bun and peer-range compatibility CI.** **v0.5.21 targets current OpenCode 1.18.x compatibility and safer releases.** Server-plugin control commands keep their locked-down acknowledgement prompt instead of creating an empty command message, `/compact` prefers the current `session.compact` TUI command, and CI now covers Ubuntu and Windows before publishing. **v0.5.20 fixes Windows TUI state writes.** Session job state is written through the OS temp directory with rename plus copy/unlink fallback and short retries, so antivirus locks and OpenCode snapshots no longer drop `/loop` jobs with `EPERM` on rename. **v0.5.19** hardens Goal Mode, package updates, and the background daemon: package installs are pinned to the installed version so OpenCode cannot keep loading an older cached release, scheduler-created goal messages no longer self-interrupt on delayed updates, finite daemon failures return nonzero, model/agent selection is supported, Windows scheduled tasks use a short launcher that stays below the `/TR` limit, and asynchronous release verification is reliable under load.
19
+ **v0.5.24 uses OpenCode's native compaction lifecycle, serializes compact-before-run work, fixes current headless summarize payloads, and validates stale `busy` recovery against a genuinely completed assistant tail.** **v0.5.23 adds immediate, token-safe recovery when a scheduler prompt/shell dispatch is rejected, without automatically replaying the prompt.** **v0.5.22 adds forward-compatible OpenCode command handling plus Bun and peer-range compatibility CI.** **v0.5.21 targets current OpenCode 1.18.x compatibility and safer releases.** Server-plugin control commands keep their locked-down acknowledgement prompt instead of creating an empty command message, `/compact` prefers the current `session.compact` TUI command, and CI now covers Ubuntu and Windows before publishing. **v0.5.20 fixes Windows TUI state writes.** Session job state is written through the OS temp directory with rename plus copy/unlink fallback and short retries, so antivirus locks and OpenCode snapshots no longer drop `/loop` jobs with `EPERM` on rename. **v0.5.19** hardens Goal Mode, package updates, and the background daemon: package installs are pinned to the installed version so OpenCode cannot keep loading an older cached release, scheduler-created goal messages no longer self-interrupt on delayed updates, finite daemon failures return nonzero, model/agent selection is supported, Windows scheduled tasks use a short launcher that stays below the `/TR` limit, and asynchronous release verification is reliable under load.
20
20
 
21
21
  The known update-related symptoms from older builds are fixed:
22
22
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bybrawe/opencode-loop",
3
- "version": "0.5.23",
3
+ "version": "0.5.24",
4
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",
@@ -18,10 +18,13 @@ async function createHarness(options = {}) {
18
18
  logs: [],
19
19
  prompts: [],
20
20
  shells: [],
21
+ summaries: [],
22
+ messageReads: [],
21
23
  toasts: [],
22
24
  tuiCommands: [],
23
25
  }
24
26
  const statuses = new Map([[sessionID, "idle"]])
27
+ const messageHistory = Array.isArray(options.messages) ? structuredClone(options.messages) : []
25
28
 
26
29
  const client = {
27
30
  app: {
@@ -35,7 +38,7 @@ async function createHarness(options = {}) {
35
38
  executeCommand: async (args) => {
36
39
  assert.ok(args?.body?.command, "tui.executeCommand must use the SDK body shape")
37
40
  records.tuiCommands.push(args.body.command)
38
- if (options.failCompact) throw new Error("simulated TUI compact failure")
41
+ if (options.failCompact || options.failTuiCompact) throw new Error("simulated TUI compact failure")
39
42
  return { data: true }
40
43
  },
41
44
  showToast: async (args) => {
@@ -95,7 +98,15 @@ async function createHarness(options = {}) {
95
98
  ),
96
99
  }
97
100
  },
98
- summarize: async () => {
101
+ messages: async (args) => {
102
+ assert.equal(args?.path?.id, sessionID)
103
+ assert.equal(args?.query?.directory, directory)
104
+ records.messageReads.push(args)
105
+ return { data: structuredClone(messageHistory) }
106
+ },
107
+ summarize: async (args) => {
108
+ assert.equal(args?.path?.id, sessionID)
109
+ records.summaries.push(args?.body)
99
110
  if (options.failCompact) throw new Error("simulated summarize failure")
100
111
  return { data: true }
101
112
  },
@@ -112,6 +123,7 @@ async function createHarness(options = {}) {
112
123
  sessionID,
113
124
  stateFile,
114
125
  statuses,
126
+ messageHistory,
115
127
  async command(command, argumentsText = "", output = { parts: [] }) {
116
128
  await hooks["command.execute.before"]({ command, sessionID, arguments: argumentsText }, output)
117
129
  return output
@@ -378,6 +390,110 @@ async function testActionRoutingAndSafety() {
378
390
  }
379
391
  }
380
392
 
393
+ async function testNativeCompactionLifecycleAndFallback() {
394
+ let h = await createHarness({ failTuiCompact: true })
395
+ try {
396
+ await h.command("loop-compact", "0s --no-now")
397
+ await h.command("loop-now", "compact")
398
+ assert.deepEqual(h.records.summaries[0], {
399
+ providerID: "test-provider",
400
+ modelID: "test-model",
401
+ auto: false,
402
+ }, "headless compact fallback must satisfy the current OpenCode summarize payload")
403
+ assert.equal(typeof h.hooks["experimental.session.compacting"], "function")
404
+ const compactOutput = { context: [], prompt: undefined }
405
+ await h.hooks["experimental.session.compacting"]({ sessionID: h.sessionID }, compactOutput)
406
+ assert.deepEqual(compactOutput, { context: [], prompt: undefined }, "loop lifecycle tracking must not rewrite OpenCode's compaction prompt")
407
+ await h.hooks.event({ event: { type: "session.compacted", properties: { sessionID: h.sessionID } } })
408
+ await delay(20)
409
+ const state = await h.readState()
410
+ assert.ok(state.jobs[0].lastFinishedAt > 0, "session.compacted must finalize an explicit compact job without waiting for stale status recovery")
411
+ } finally {
412
+ await h.cleanup()
413
+ }
414
+
415
+ h = await createHarness()
416
+ try {
417
+ await h.command("loop", "5m --no-now --name compact-chain --compact-every 1 --verify 'node -e process.exitCode=7' --pause-on-verify-fail continue after compaction")
418
+ const seeded = await h.readState()
419
+ seeded.jobs[0].runCount = 1
420
+ seeded.jobs[0].lastRunAt = 0
421
+ await fs.writeFile(h.stateFile, JSON.stringify(seeded, null, 2), "utf8")
422
+
423
+ await h.command("loop-now", "compact-chain")
424
+ assert.equal(h.records.tuiCommands.length, 1, "compact-every must start compaction")
425
+ assert.equal(h.actionTexts().length, 0, "the scheduled action must not overlap a pending compaction")
426
+ assert.equal((await h.readState()).jobs[0].runCount, 1, "compaction-only phase must not count as a normal loop run")
427
+
428
+ await h.hooks["experimental.session.compacting"]({ sessionID: h.sessionID }, { context: [], prompt: undefined })
429
+ await h.hooks.event({ event: { type: "session.compacted", properties: { sessionID: h.sessionID } } })
430
+ await delay(20)
431
+ assert.equal((await h.readState()).jobs[0].runCount, 1, "native compaction completion must only release the deferred action")
432
+ } finally {
433
+ await h.cleanup()
434
+ }
435
+
436
+ h = await createHarness()
437
+ try {
438
+ await h.command("loop", "5m --no-now --name compact-idle-fallback --compact-every 1 --verify 'node -e process.exitCode=7' --pause-on-verify-fail continue after fallback compaction")
439
+ const seeded = await h.readState()
440
+ seeded.jobs[0].runCount = 1
441
+ seeded.jobs[0].lastRunAt = 0
442
+ await fs.writeFile(h.stateFile, JSON.stringify(seeded, null, 2), "utf8")
443
+ await h.command("loop-now", "compact-idle-fallback")
444
+ assert.equal(h.actionTexts().length, 0)
445
+ h.statuses.set(h.sessionID, "idle")
446
+ await h.command("loop-now", "compact-idle-fallback")
447
+ const fallbackState = await h.readState()
448
+ assert.equal(fallbackState.jobs[0].failureCount || 0, 0, "idle-only compaction fallback must not run normal verify logic")
449
+ assert.equal(fallbackState.jobs[0].paused, false, "idle-only compaction fallback must not pause the job through normal run finalization")
450
+ } finally {
451
+ await h.cleanup()
452
+ }
453
+ }
454
+
455
+ async function testStaleBusyUsesCompletedAssistantTail() {
456
+ let h = await createHarness()
457
+ try {
458
+ await h.command("loop", "5m --no-now --name stale-complete continue safely")
459
+ await h.command("loop-now", "stale-complete")
460
+ h.statuses.set(h.sessionID, "busy")
461
+ const completedAt = Date.now() + 5
462
+ h.messageHistory.splice(0, h.messageHistory.length,
463
+ { info: { id: "usr_tail", sessionID: h.sessionID, role: "user", time: { created: completedAt - 2 } }, parts: [] },
464
+ { info: { id: "asst_tail", sessionID: h.sessionID, role: "assistant", time: { created: completedAt - 1, completed: completedAt } }, parts: [] },
465
+ )
466
+ await h.command("loop-now", "stale-complete")
467
+ const state = await h.readState()
468
+ assert.ok(state.jobs[0].lastFinishedAt > 0, "a completed assistant tail must override a stale busy status")
469
+ assert.ok(h.records.messageReads.length > 0, "busy recovery must cross-check message history")
470
+ } finally {
471
+ await h.cleanup()
472
+ }
473
+
474
+ h = await createHarness()
475
+ try {
476
+ await h.command("loop", "5m --no-now --name stale-incomplete continue safely")
477
+ const seeded = await h.readState()
478
+ seeded.jobs[0].staleActiveRecoveryMs = 1
479
+ await fs.writeFile(h.stateFile, JSON.stringify(seeded, null, 2), "utf8")
480
+ await h.command("loop-now", "stale-incomplete")
481
+ await delay(10)
482
+ h.statuses.set(h.sessionID, "busy")
483
+ const createdAt = Date.now()
484
+ h.messageHistory.splice(0, h.messageHistory.length,
485
+ { info: { id: "usr_running", sessionID: h.sessionID, role: "user", time: { created: createdAt - 1 } }, parts: [] },
486
+ { info: { id: "asst_running", sessionID: h.sessionID, role: "assistant", time: { created: createdAt } }, parts: [] },
487
+ )
488
+ await h.command("loop-now", "stale-incomplete")
489
+ const state = await h.readState()
490
+ assert.equal(state.jobs[0].lastFinishedAt, undefined, "an unfinished assistant tail must never be force-finalized just because the active-run timeout elapsed")
491
+ assert.equal(h.actionTexts().length, 1, "unfinished work must not be overlapped by a replacement prompt")
492
+ } finally {
493
+ await h.cleanup()
494
+ }
495
+ }
496
+
381
497
  async function testPromptDispatchFailureRecovery() {
382
498
  const h = await createHarness({ failPrompt: true, promptFailureDelayMs: 25 })
383
499
  try {
@@ -593,6 +709,8 @@ await testParserAndPresets()
593
709
  await testLifecycleAndCommandDedupe()
594
710
  await testWatchScheduling()
595
711
  await testActionRoutingAndSafety()
712
+ await testNativeCompactionLifecycleAndFallback()
713
+ await testStaleBusyUsesCompletedAssistantTail()
596
714
  await testPromptDispatchFailureRecovery()
597
715
  await testStopsPreflightAndGoalLifecycle()
598
716
  await testLoopOwnedGoalMessageUpdatesDoNotSelfInterrupt()
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)