@bybrawe/opencode-loop 0.5.22 → 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,20 @@
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
+
11
+ ## 0.5.23
12
+
13
+ - Track non-blocking `session.prompt` and `session.shell` dispatch promises instead of treating every fire-and-forget request as successfully started.
14
+ - Recover a rejected scheduler dispatch immediately: clear only the matching active run token, invalidate cached busy status, persist the failure, honor `--max-failures`, and reschedule through the normal scheduler path.
15
+ - Never replay a rejected prompt automatically; this avoids duplicate turns when OpenCode status/events are delayed or multiple instances are involved.
16
+ - Added a deterministic regression test for delayed prompt rejection and verified the error remains observable in plugin logs.
17
+
3
18
  ## 0.5.22
4
19
 
5
20
  - Hardened compatibility with current OpenCode 1.18.15 while preserving the existing `>=1.4.0` peer range.
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.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.22",
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) => {
@@ -75,6 +78,10 @@ async function createHarness(options = {}) {
75
78
  assert.equal(args?.path?.id, sessionID)
76
79
  const text = args.body.parts.map((part) => part.text || "").join("\n")
77
80
  records.prompts.push({ text, noReply: args.body.noReply === true, agent: args.body.agent, model: args.body.model })
81
+ if (options.failPrompt) {
82
+ await delay(options.promptFailureDelayMs ?? 20)
83
+ throw new Error(options.promptFailureMessage || "simulated prompt dispatch failure")
84
+ }
78
85
  return { data: true }
79
86
  },
80
87
  shell: async (args) => {
@@ -91,7 +98,15 @@ async function createHarness(options = {}) {
91
98
  ),
92
99
  }
93
100
  },
94
- 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)
95
110
  if (options.failCompact) throw new Error("simulated summarize failure")
96
111
  return { data: true }
97
112
  },
@@ -108,6 +123,7 @@ async function createHarness(options = {}) {
108
123
  sessionID,
109
124
  stateFile,
110
125
  statuses,
126
+ messageHistory,
111
127
  async command(command, argumentsText = "", output = { parts: [] }) {
112
128
  await hooks["command.execute.before"]({ command, sessionID, arguments: argumentsText }, output)
113
129
  return output
@@ -374,6 +390,136 @@ async function testActionRoutingAndSafety() {
374
390
  }
375
391
  }
376
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
+
497
+ async function testPromptDispatchFailureRecovery() {
498
+ const h = await createHarness({ failPrompt: true, promptFailureDelayMs: 25 })
499
+ try {
500
+ await h.command("loop", "0s --no-now --name dispatch-failure --max-failures 1 recover from a rejected prompt dispatch")
501
+ await h.command("loop-now", "dispatch-failure")
502
+
503
+ let state
504
+ for (let attempt = 0; attempt < 40; attempt++) {
505
+ state = await h.readState()
506
+ if (state.jobs[0]?.lastFailureReason === "dispatch_failed") break
507
+ await delay(25)
508
+ }
509
+
510
+ const job = state.jobs[0]
511
+ assert.equal(job.failureCount, 1, "a rejected prompt dispatch must count as a scheduler failure")
512
+ assert.equal(job.paused, true, "--max-failures must pause after a rejected prompt dispatch")
513
+ assert.equal(job.lastFailureReason, "dispatch_failed")
514
+ assert.match(job.lastDispatchFailure, /simulated prompt dispatch failure/)
515
+ assert.ok(job.lastDispatchFailureAt > 0)
516
+ assert.equal(h.actionTexts().length, 1, "dispatch recovery must never replay the prompt automatically")
517
+ assert.ok(h.records.logs.some((entry) => entry.message === "session.prompt failed"), "the SDK rejection must remain observable")
518
+ } finally {
519
+ await h.cleanup()
520
+ }
521
+ }
522
+
377
523
  async function testStopsPreflightAndGoalLifecycle() {
378
524
  let h = await createHarness()
379
525
  try {
@@ -563,6 +709,9 @@ await testParserAndPresets()
563
709
  await testLifecycleAndCommandDedupe()
564
710
  await testWatchScheduling()
565
711
  await testActionRoutingAndSafety()
712
+ await testNativeCompactionLifecycleAndFallback()
713
+ await testStaleBusyUsesCompletedAssistantTail()
714
+ await testPromptDispatchFailureRecovery()
566
715
  await testStopsPreflightAndGoalLifecycle()
567
716
  await testLoopOwnedGoalMessageUpdatesDoNotSelfInterrupt()
568
717
  await testInitializationDoesNotWaitForLocalApi()
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
 
@@ -510,9 +511,11 @@ async function sdkCall(method, ...argsList) {
510
511
  }
511
512
 
512
513
  function fireSdk(client, label, method, ...argsList) {
513
- Promise.resolve()
514
- .then(() => sdkCall(method, ...argsList))
515
- .catch((error) => log(client, "warn", `${label} failed`, { error: sdkErrorMessage(error) }))
514
+ const pending = Promise.resolve().then(() => sdkCall(method, ...argsList))
515
+ void pending.catch((error) => {
516
+ log(client, "warn", `${label} failed`, { error: sdkErrorMessage(error) }).catch(() => {})
517
+ })
518
+ return pending
516
519
  }
517
520
 
518
521
  async function executeTuiCommand(client, command) {
@@ -530,11 +533,72 @@ function compactTuiCommandName(command = "compact") {
530
533
  return undefined
531
534
  }
532
535
 
533
- async function compactSession(client, sessionID) {
534
- // OpenCode's TUI API accepts legacy keybind aliases (session_compact) in
535
- // current builds, while some older docs/examples mention the event value
536
- // (session.compact). Try the alias first, then the event value, then the
537
- // 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.
538
602
  for (const command of ["session.compact", "session_compact"]) {
539
603
  try {
540
604
  await executeTuiCommand(client, command)
@@ -544,17 +608,21 @@ async function compactSession(client, sessionID) {
544
608
  }
545
609
  }
546
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 }
547
615
  await sdkCall(
548
616
  client.session.summarize.bind(client.session),
549
- { path: { id: sessionID }, body: {} },
550
- { path: { sessionID }, body: {} },
551
- { sessionID },
617
+ { path: { id: sessionID }, body },
618
+ { path: { sessionID }, body },
619
+ { sessionID, ...body },
552
620
  )
553
621
  return true
554
622
  } catch (error) {
555
623
  await log(client, "warn", "session.summarize fallback failed", { error: sdkErrorMessage(error) })
556
624
  }
557
- 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")
558
626
  return false
559
627
  }
560
628
 
@@ -848,6 +916,7 @@ function disposeRuntime(directory, client) {
848
916
  sessionStatuses.delete(sessionID)
849
917
  sessionStatusSeenAt.delete(sessionID)
850
918
  sessionExecutionContexts.delete(sessionID)
919
+ loopCompactionRequests.delete(sessionID)
851
920
  for (const key of handledCommands.keys()) if (key.startsWith(`${sessionID}:`)) handledCommands.delete(key)
852
921
  for (const key of handledCommandEvents.keys()) if (key.startsWith(`${sessionID}:`)) handledCommandEvents.delete(key)
853
922
  }
@@ -1077,15 +1146,18 @@ async function ensureBranch(directory, job, client, sessionID) {
1077
1146
  return job
1078
1147
  }
1079
1148
 
1080
- async function maybeCompact(client, sessionID, job) {
1149
+ async function maybeCompact(directory, client, sessionID, job) {
1081
1150
  const dueRuns = job.compactEveryRuns > 0 && (job.runCount || 0) > 0 && (job.runCount || 0) % job.compactEveryRuns === 0 && job.lastCompactRunCount !== job.runCount
1082
1151
  const dueTime = job.compactEveryMs > 0 && (!job.lastCompactAt || now() - job.lastCompactAt >= job.compactEveryMs)
1083
- if (!dueRuns && !dueTime) return job
1084
- 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)) {
1085
1155
  job.lastCompactAt = now()
1086
1156
  job.lastCompactRunCount = job.runCount || 0
1157
+ return { job, started: true }
1087
1158
  }
1088
- return job
1159
+ loopCompactionRequests.delete(sessionID)
1160
+ return { job, started: false }
1089
1161
  }
1090
1162
 
1091
1163
  async function snapshotPaths(directory, files) {
@@ -1224,12 +1296,21 @@ function staleActiveRun(sessionID) {
1224
1296
  async function canFinalizeActiveRun(directory, client, sessionID, active, options = {}) {
1225
1297
  if (hasActiveToolCalls(sessionID) || hasBusyDescendant(sessionID)) return false
1226
1298
  if (!options.requireIdle && !options.forceStale) return true
1227
- if (options.forceStale && staleActiveRun(sessionID)) return true
1228
- 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)
1229
1305
 
1230
1306
  const live = await readLiveSessionStatus(client, sessionID, directory)
1231
- 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
+ }
1232
1312
 
1313
+ if (options.forceStale && completion === "unknown" && staleActiveRun(sessionID)) return true
1233
1314
  const cached = sessionStatuses.get(sessionID)
1234
1315
  const seenAt = sessionStatusSeenAt.get(sessionID) || 0
1235
1316
  return cached === "idle" && seenAt > (active.startedAt || 0)
@@ -1297,10 +1378,21 @@ async function sessionStatusType(client, sessionID, directory, options = {}) {
1297
1378
  // plugin-injected turn until the next user command touches the session.
1298
1379
  // When the only reason we still think the session is busy is our own stale
1299
1380
  // active-run guard, recover instead of waiting for another manual command.
1300
- if ((live.type === "busy" || live.type === "retry") && options.recoverStaleActive !== false && staleActiveRun(sessionID)) {
1301
- sessionStatuses.set(sessionID, "idle")
1302
- sessionStatusSeenAt.set(sessionID, now())
1303
- 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
+ }
1304
1396
  }
1305
1397
  sessionStatuses.set(sessionID, live.type)
1306
1398
  sessionStatusSeenAt.set(sessionID, now())
@@ -1446,9 +1538,83 @@ function dueJobs(state, force = false) {
1446
1538
  function clearActiveRun(sessionID) {
1447
1539
  const active = activeRuns.get(sessionID)
1448
1540
  if (active?.timer) clearTimeout(active.timer)
1541
+ const compact = loopCompactionRequests.get(sessionID)
1542
+ if (!compact || !active || compact.jobId === active.jobId) loopCompactionRequests.delete(sessionID)
1449
1543
  activeRuns.delete(sessionID)
1450
1544
  }
1451
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
+
1588
+ async function recoverActiveDispatchFailure(directory, client, sessionID, jobId, runToken, error) {
1589
+ const active = activeRuns.get(sessionID)
1590
+ if (!active || active.jobId !== jobId || active.runToken !== runToken) return false
1591
+
1592
+ clearActiveRun(sessionID)
1593
+ sessionStatuses.delete(sessionID)
1594
+ sessionStatusSeenAt.delete(sessionID)
1595
+
1596
+ const message = sdkErrorMessage(error)
1597
+ const state = await readState(directory, sessionID)
1598
+ const job = (state.jobs || []).find((candidate) => candidate.id === jobId)
1599
+ if (job) {
1600
+ job.failureCount = (job.failureCount || 0) + 1
1601
+ job.lastFailureReason = "dispatch_failed"
1602
+ job.lastDispatchFailure = message.slice(0, 4000)
1603
+ job.lastDispatchFailureAt = now()
1604
+ if (job.maxFailures > 0 && job.failureCount >= job.maxFailures) {
1605
+ job.paused = true
1606
+ await notifyJob(directory, job, "dispatch_failed")
1607
+ }
1608
+ state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
1609
+ await writeState(directory, sessionID, state)
1610
+ }
1611
+
1612
+ await appendLoopLog(directory, "dispatch-error", { sessionID, job: job?.name || jobId, error: message })
1613
+ await toast(client, `Loop dispatch failed${job?.paused ? " and paused" : ""}: ${message}`, job?.paused ? "error" : "warning")
1614
+ await scheduleDueWork(directory, client, sessionID, BUSY_RETRY_MS)
1615
+ return true
1616
+ }
1617
+
1452
1618
  function goalReportPath(directory, sessionID, job) {
1453
1619
  return path.join(stateDir(directory), GOAL_REPORT_DIR, `${safeID(sessionID)}-${safeID(job.name || job.id)}.md`)
1454
1620
  }
@@ -1686,6 +1852,20 @@ async function finalizeActiveRun(directory, client, sessionID, options = {}) {
1686
1852
  if (!active) return
1687
1853
  if (!await canFinalizeActiveRun(directory, client, sessionID, active, options)) return false
1688
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
+ }
1689
1869
  clearActiveRun(sessionID)
1690
1870
  const state = await readState(directory, sessionID)
1691
1871
  let job = (state.jobs || []).find((candidate) => candidate.id === active.jobId)
@@ -1749,8 +1929,10 @@ async function fireAction(directory, client, sessionID, job) {
1749
1929
  const agent = job.agent || "build"
1750
1930
  const model = normalizedModelRef(job.model)
1751
1931
  if (kind === "compact") {
1752
- const ok = await compactSession(client, sessionID)
1753
- 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 }
1754
1936
  }
1755
1937
  if (kind === "command") {
1756
1938
  const normalized = action.startsWith("/") ? action.slice(1) : action
@@ -1762,8 +1944,10 @@ async function fireAction(directory, client, sessionID, job) {
1762
1944
  const tuiCommand = compactTuiCommandName(command)
1763
1945
  if (tuiCommand) {
1764
1946
  guardLoopOwnedUserMessage(sessionID)
1765
- await compactSession(client, sessionID)
1766
- 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 }
1767
1951
  }
1768
1952
  guardLoopOwnedUserMessage(sessionID)
1769
1953
  const commandBody = { command, arguments: argumentsText, agent }
@@ -1786,7 +1970,7 @@ async function fireAction(directory, client, sessionID, job) {
1786
1970
  guardLoopOwnedUserMessage(sessionID)
1787
1971
  const shellBody = { command, agent }
1788
1972
  if (model) shellBody.model = model
1789
- fireSdk(
1973
+ const dispatch = fireSdk(
1790
1974
  client,
1791
1975
  "session.shell",
1792
1976
  client.session.shell.bind(client.session),
@@ -1794,7 +1978,7 @@ async function fireAction(directory, client, sessionID, job) {
1794
1978
  { path: { sessionID }, body: shellBody },
1795
1979
  { sessionID, ...shellBody },
1796
1980
  )
1797
- return { startsAssistantTurn: true }
1981
+ return { startsAssistantTurn: true, dispatch }
1798
1982
  }
1799
1983
  const prompt = await buildPrompt(directory, job)
1800
1984
  const prefix = kind === "goal"
@@ -1806,7 +1990,7 @@ ${prompt}`
1806
1990
  guardLoopOwnedUserMessage(sessionID)
1807
1991
  const promptBody = { agent, parts: [{ type: "text", text: promptText }] }
1808
1992
  if (model) promptBody.model = model
1809
- fireSdk(
1993
+ const dispatch = fireSdk(
1810
1994
  client,
1811
1995
  "session.prompt",
1812
1996
  client.session.prompt.bind(client.session),
@@ -1814,7 +1998,7 @@ ${prompt}`
1814
1998
  { path: { sessionID }, body: promptBody },
1815
1999
  { sessionID, ...promptBody },
1816
2000
  )
1817
- return { startsAssistantTurn: true }
2001
+ return { startsAssistantTurn: true, dispatch }
1818
2002
  }
1819
2003
 
1820
2004
  async function maybeRunDueJobs(directory, client, sessionID, options = {}) {
@@ -1907,7 +2091,25 @@ async function maybeRunDueJobs(directory, client, sessionID, options = {}) {
1907
2091
  }
1908
2092
 
1909
2093
  job = await ensureBranch(directory, job, client, sessionID)
1910
- 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
+ }
1911
2113
  job.watchTriggered = false
1912
2114
  job.lastRunAt = now()
1913
2115
  job.runCount = (job.runCount || 0) + 1
@@ -1939,7 +2141,21 @@ async function maybeRunDueJobs(directory, client, sessionID, options = {}) {
1939
2141
  }
1940
2142
  let timer
1941
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)
1942
- activeRuns.set(sessionID, { jobId: job.id, job, startedAt: now(), timer })
2144
+ const runToken = `${job.id}:${now().toString(36)}:${Math.random().toString(16).slice(2)}`
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
+ }
2153
+ if (result.dispatch) {
2154
+ void result.dispatch.catch((error) => {
2155
+ recoverActiveDispatchFailure(directory, client, sessionID, job.id, runToken, error)
2156
+ .catch((recoveryError) => log(client, "error", "dispatch recovery failed", { error: sdkErrorMessage(recoveryError) }))
2157
+ })
2158
+ }
1943
2159
  sessionStatuses.set(sessionID, "busy")
1944
2160
  sessionStatusSeenAt.set(sessionID, now())
1945
2161
  await reschedule(BUSY_RETRY_MS)
@@ -2305,7 +2521,9 @@ export const OpenCodeLoopPlugin = async ({ client, directory }) => {
2305
2521
  "command.execute.before": async (input, output) => { await handleCommand(directory, client, input, undefined, undefined, output) },
2306
2522
  "tool.execute.before": async (input) => { markToolCallActive(input) },
2307
2523
  "tool.execute.after": async (input) => { markToolCallFinished(input) },
2524
+ "experimental.session.compacting": async (input) => { await noteLoopCompactionStarted(directory, input?.sessionID) },
2308
2525
  event: async ({ event }) => {
2526
+ if (event.type === "session.compacted") await noteLoopCompactionCompleted(directory, client, event?.properties?.sessionID)
2309
2527
  updateSessionRelationshipFromEvent(event)
2310
2528
  if (event.type === "message.updated") updateSessionExecutionContext(event?.properties?.info)
2311
2529
  updateToolActivityFromEvent(event)