@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/CHANGELOG.md +8 -0
- package/README.md +176 -1004
- package/package.json +1 -1
- package/scripts/comprehensive-test.mjs +120 -2
- package/scripts/install-node.mjs +204 -51
- package/scripts/install-test.mjs +50 -4
- package/scripts/smoke-test.mjs +28 -0
- package/src/index.js +205 -26
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
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1230
|
-
|
|
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
|
|
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
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
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
|
-
|
|
1785
|
-
|
|
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
|
-
|
|
1798
|
-
|
|
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
|
-
|
|
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)
|