@bybrawe/opencode-loop 0.5.27 → 0.5.29

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.
Files changed (80) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +1 -1
  3. package/package.json +7 -4
  4. package/scripts/command-lifecycle-test.mjs +47 -0
  5. package/scripts/command-router-test.mjs +175 -0
  6. package/scripts/compaction-runtime-test.mjs +128 -0
  7. package/scripts/comprehensive-test.mjs +29 -7
  8. package/scripts/comprehensive-watchdog.mjs +32 -0
  9. package/scripts/executor-runtime-test.mjs +310 -0
  10. package/scripts/fixtures/opencode2-real-adapter-probe.js +70 -0
  11. package/scripts/goal-command-handlers-test.mjs +236 -0
  12. package/scripts/goal-policy-test.mjs +143 -0
  13. package/scripts/goal-runtime-test.mjs +203 -0
  14. package/scripts/goal-steering-test.mjs +153 -0
  15. package/scripts/host-adapter-contract-test.mjs +147 -0
  16. package/scripts/host-goal-steering-canary.mjs +443 -0
  17. package/scripts/host-loop-canary.mjs +68 -12
  18. package/scripts/job-workspace-test.mjs +164 -0
  19. package/scripts/loop-command-handlers-test.mjs +279 -0
  20. package/scripts/loop-registration-test.mjs +220 -0
  21. package/scripts/opencode2-host-canary.mjs +194 -0
  22. package/scripts/opencode2-loop-canary.mjs +433 -0
  23. package/scripts/runtime-contract-test.mjs +59 -0
  24. package/scripts/runtime-event-test.mjs +78 -0
  25. package/scripts/runtime-scope-test.mjs +46 -0
  26. package/scripts/scheduler-runtime-test.mjs +147 -0
  27. package/scripts/session-activity-test.mjs +95 -0
  28. package/scripts/session-manager-test.mjs +48 -0
  29. package/scripts/session-status-test.mjs +220 -0
  30. package/scripts/v2-command-adapter-test.mjs +113 -0
  31. package/scripts/v2-command-runtime-test.mjs +98 -0
  32. package/scripts/v2-event-bridge-test.mjs +72 -0
  33. package/scripts/v2-event-stream-contract-test.mjs +38 -0
  34. package/scripts/v2-host-contract-test.mjs +82 -0
  35. package/scripts/v2-plugin-sentinel-test.mjs +136 -0
  36. package/scripts/v2-prompt-interval-test.mjs +171 -0
  37. package/scripts/v2-prompt-runtime-test.mjs +267 -0
  38. package/scripts/v2-runtime-adapter-test.mjs +326 -0
  39. package/src/index.js +3737 -2220
  40. package/src/opencode/v1.js +1 -0
  41. package/src/source/core/args.js +281 -0
  42. package/src/source/core/jobs.js +80 -0
  43. package/src/source/core/process.js +51 -0
  44. package/src/source/core/state.js +211 -0
  45. package/src/source/legacy-v1.js +252 -0
  46. package/src/source/opencode/adapter.js +21 -0
  47. package/src/source/opencode/command-router.js +103 -0
  48. package/src/source/opencode/commands.js +83 -0
  49. package/src/source/opencode/goal-commands.js +107 -0
  50. package/src/source/opencode/host.js +134 -0
  51. package/src/source/opencode/loop-commands.js +195 -0
  52. package/src/source/opencode/loop-registration.js +100 -0
  53. package/src/source/opencode/messages.js +55 -0
  54. package/src/source/opencode/sdk.js +38 -0
  55. package/src/source/opencode/session-context.js +50 -0
  56. package/src/source/opencode2/capabilities.js +71 -0
  57. package/src/source/opencode2/commands.js +60 -0
  58. package/src/source/opencode2/event-bridge.js +174 -0
  59. package/src/source/opencode2/events.js +64 -0
  60. package/src/source/opencode2/experimental.js +47 -0
  61. package/src/source/opencode2/host-contract.js +94 -0
  62. package/src/source/opencode2/prompt-runtime.js +381 -0
  63. package/src/source/opencode2/runtime-adapter.js +56 -0
  64. package/src/source/opencode2/status.js +40 -0
  65. package/src/source/runtime/compaction.js +122 -0
  66. package/src/source/runtime/events.js +113 -0
  67. package/src/source/runtime/executor.js +558 -0
  68. package/src/source/runtime/goal-policy.js +77 -0
  69. package/src/source/runtime/goal-runtime.js +231 -0
  70. package/src/source/runtime/goal-steering.js +198 -0
  71. package/src/source/runtime/job-workspace.js +148 -0
  72. package/src/source/runtime/observer.js +23 -0
  73. package/src/source/runtime/scheduler.js +212 -0
  74. package/src/source/runtime/scope.js +56 -0
  75. package/src/source/runtime/session-activity.js +112 -0
  76. package/src/source/runtime/session-manager.js +100 -0
  77. package/src/source/runtime/session-registry.js +58 -0
  78. package/src/source/runtime/session-status.js +197 -0
  79. package/src/source/runtime/timers.js +45 -0
  80. package/src/source/v1.js +69 -0
@@ -0,0 +1,197 @@
1
+ import { now as defaultNow } from "../core/args.js"
2
+ import { appendLoopLog as defaultAppendLoopLog } from "../core/process.js"
3
+ import { sdkData, sdkError } from "../opencode/sdk.js"
4
+ import { activeRunCompletionFromMessages as defaultActiveRunCompletionFromMessages } from "../opencode/host.js"
5
+ import {
6
+ sessionParents,
7
+ sessionStatuses,
8
+ sessionStatusSeenAt,
9
+ hasActiveToolCalls,
10
+ isDescendantSession,
11
+ hasBusyDescendant,
12
+ } from "./session-activity.js"
13
+
14
+ const DEFAULT_STALE_ACTIVE_RECOVERY_MS = 45_000
15
+ const DEFAULT_SESSION_STATUS_CACHE_MS = 1_500
16
+
17
+ function positiveNumber(value, fallback) {
18
+ const number = Number(value)
19
+ return Number.isFinite(number) && number > 0 ? number : fallback
20
+ }
21
+
22
+ function nonNegativeNumber(value, fallback) {
23
+ const number = Number(value)
24
+ return Number.isFinite(number) && number >= 0 ? number : fallback
25
+ }
26
+
27
+ export function createSessionStatusRuntime(options = {}) {
28
+ const activeRuns = options.activeRuns
29
+ if (!(activeRuns instanceof Map)) throw new TypeError("createSessionStatusRuntime requires activeRuns Map")
30
+ const now = typeof options.now === "function" ? options.now : defaultNow
31
+ const appendLoopLog = typeof options.appendLoopLog === "function" ? options.appendLoopLog : defaultAppendLoopLog
32
+ const activeRunCompletionFromMessages = typeof options.activeRunCompletionFromMessages === "function"
33
+ ? options.activeRunCompletionFromMessages
34
+ : defaultActiveRunCompletionFromMessages
35
+ const staleActiveRecoveryMs = positiveNumber(options.staleActiveRecoveryMs, DEFAULT_STALE_ACTIVE_RECOVERY_MS)
36
+ const sessionStatusCacheMs = nonNegativeNumber(options.sessionStatusCacheMs, DEFAULT_SESSION_STATUS_CACHE_MS)
37
+
38
+ function markSessionStatus(sessionID, type, observedAt = now()) {
39
+ if (typeof sessionID !== "string" || typeof type !== "string") return false
40
+ sessionStatuses.set(sessionID, type)
41
+ sessionStatusSeenAt.set(sessionID, observedAt)
42
+ return true
43
+ }
44
+
45
+ function clearSessionStatus(sessionID) {
46
+ sessionStatuses.delete(sessionID)
47
+ sessionStatusSeenAt.delete(sessionID)
48
+ }
49
+
50
+ function updateSessionStatusFromEvent(event) {
51
+ const sessionID = event?.properties?.sessionID
52
+ if (typeof sessionID !== "string") return undefined
53
+ if (event?.type === "session.idle") {
54
+ markSessionStatus(sessionID, "idle")
55
+ return { sessionID, idle: true }
56
+ }
57
+ if (event?.type === "session.status") {
58
+ const status = event?.properties?.status
59
+ const type = status && typeof status === "object" ? status.type : undefined
60
+ if (typeof type === "string") markSessionStatus(sessionID, type)
61
+ return { sessionID, idle: type === "idle" }
62
+ }
63
+ return undefined
64
+ }
65
+
66
+ function staleActiveRun(sessionID) {
67
+ const active = activeRuns.get(sessionID)
68
+ if (!active) return false
69
+ const age = now() - (active.startedAt || 0)
70
+ const configured = Number(active.job?.staleActiveRecoveryMs || active.job?.activeRecoveryMs || 0)
71
+ const threshold = Number.isFinite(configured) && configured > 0 ? configured : staleActiveRecoveryMs
72
+ return age >= threshold
73
+ }
74
+
75
+ async function readLiveSessionStatus(client, sessionID, directory) {
76
+ const statusMethod = client?.session?.status
77
+ if (typeof statusMethod !== "function") return undefined
78
+ const argsList = []
79
+ if (directory) argsList.push({ query: { directory } }, { directory }, { workspace: directory })
80
+ argsList.push({})
81
+ for (const args of argsList) {
82
+ try {
83
+ const result = await statusMethod.call(client.session, args)
84
+ const error = sdkError(result)
85
+ if (error) continue
86
+ const data = sdkData(result)
87
+ if (!data || typeof data !== "object" || Array.isArray(data)) continue
88
+ const observedAt = now()
89
+ for (const [observedSessionID, observedStatus] of Object.entries(data)) {
90
+ const observedType = observedStatus && typeof observedStatus === "object" ? observedStatus.type : undefined
91
+ if (typeof observedType !== "string") continue
92
+ markSessionStatus(observedSessionID, observedType, observedAt)
93
+ }
94
+ // OpenCode's status list contains active sessions; idle sessions are
95
+ // normally omitted. Clear a completed descendant that was previously busy.
96
+ for (const childID of sessionParents.keys()) {
97
+ if (!isDescendantSession(childID, sessionID) || data[childID]) continue
98
+ markSessionStatus(childID, "idle", observedAt)
99
+ }
100
+ if (hasBusyDescendant(sessionID)) return { type: "busy", source: "descendant" }
101
+ const status = data?.[sessionID]
102
+ const type = status && typeof status === "object" ? status.type : undefined
103
+ if (typeof type === "string") return { type, source: "sdk" }
104
+ return { type: "idle", source: "sdk" }
105
+ } catch {}
106
+ }
107
+ return undefined
108
+ }
109
+
110
+ async function canFinalizeActiveRun(directory, client, sessionID, active, options = {}) {
111
+ if (hasActiveToolCalls(sessionID) || hasBusyDescendant(sessionID)) return false
112
+ if (!options.requireIdle && !options.forceStale) return true
113
+
114
+ const completion = options.forceStale
115
+ ? await activeRunCompletionFromMessages(directory, client, sessionID, active)
116
+ : undefined
117
+ if (completion === "completed") return true
118
+ if (!options.requireIdle) return completion === "unknown" && staleActiveRun(sessionID)
119
+
120
+ const live = await readLiveSessionStatus(client, sessionID, directory)
121
+ if (live?.type === "idle") return true
122
+ if (live?.type) {
123
+ if ((live.type === "busy" || live.type === "retry") && options.forceStale && completion === "unknown" && staleActiveRun(sessionID)) return true
124
+ return false
125
+ }
126
+
127
+ if (options.forceStale && completion === "unknown" && staleActiveRun(sessionID)) return true
128
+ const cached = sessionStatuses.get(sessionID)
129
+ const seenAt = sessionStatusSeenAt.get(sessionID) || 0
130
+ return cached === "idle" && seenAt > (active.startedAt || 0)
131
+ }
132
+
133
+ async function sessionStatusType(client, sessionID, directory, options = {}) {
134
+ // OpenCode can briefly report an idle session while a long-running tool or
135
+ // subtask is still executing. Tool lifecycle hooks are the more specific
136
+ // signal here, so never enqueue another turn until every active call ends.
137
+ if (hasActiveToolCalls(sessionID) || hasBusyDescendant(sessionID)) {
138
+ markSessionStatus(sessionID, "busy")
139
+ return "busy"
140
+ }
141
+
142
+ const cached = sessionStatuses.get(sessionID)
143
+ const seenAt = sessionStatusSeenAt.get(sessionID) || 0
144
+
145
+ // Idle is safe to trust until OpenCode tells us otherwise. Busy/retry is only
146
+ // trusted briefly: OpenCode custom commands such as /loop-status create their
147
+ // own short assistant turn, and some TUI builds do not always emit the final
148
+ // idle event after that turn. If we cache busy forever, due loop work can get
149
+ // stuck at "due in every idle" until the user types another command.
150
+ if (cached === "idle") return cached
151
+ if (cached && now() - seenAt < sessionStatusCacheMs) return cached
152
+
153
+ const live = await readLiveSessionStatus(client, sessionID, directory)
154
+ if (live?.type) {
155
+ // Some OpenCode 1.15.x TUI builds can leave session.status at busy after a
156
+ // plugin-injected turn until the next user command touches the session.
157
+ // When the only reason we still think the session is busy is our own stale
158
+ // active-run guard, recover instead of waiting for another manual command.
159
+ if ((live.type === "busy" || live.type === "retry") && options.recoverStaleActive !== false) {
160
+ const active = activeRuns.get(sessionID)
161
+ if (active) {
162
+ const completion = await activeRunCompletionFromMessages(directory, client, sessionID, active)
163
+ if (completion === "completed" || (completion === "unknown" && staleActiveRun(sessionID))) {
164
+ markSessionStatus(sessionID, "idle")
165
+ await appendLoopLog(directory, completion === "completed" ? "status-message-complete-recovery" : "status-stale-recovery", {
166
+ sessionID,
167
+ job: active.job?.name || active.jobId,
168
+ startedAt: active.startedAt,
169
+ })
170
+ return "idle"
171
+ }
172
+ }
173
+ }
174
+ markSessionStatus(sessionID, live.type)
175
+ return live.type
176
+ }
177
+
178
+ const fallback = activeRuns.has(sessionID) && !staleActiveRun(sessionID) ? "busy" : "idle"
179
+ markSessionStatus(sessionID, fallback)
180
+ return fallback
181
+ }
182
+
183
+ async function sessionIsIdle(client, sessionID, directory, options = {}) {
184
+ return await sessionStatusType(client, sessionID, directory, options) === "idle"
185
+ }
186
+
187
+ return {
188
+ markSessionStatus,
189
+ clearSessionStatus,
190
+ updateSessionStatusFromEvent,
191
+ staleActiveRun,
192
+ canFinalizeActiveRun,
193
+ readLiveSessionStatus,
194
+ sessionStatusType,
195
+ sessionIsIdle,
196
+ }
197
+ }
@@ -0,0 +1,45 @@
1
+ function createOwnedTimer(scope, callback, delay, repeat, api, ref) {
2
+ if (!scope?.isActive?.()) return undefined
3
+ let active = true
4
+ let release
5
+ let handle
6
+
7
+ const invoke = scope.guard((...args) => {
8
+ if (!active) return
9
+ if (!repeat) {
10
+ active = false
11
+ release?.()
12
+ }
13
+ callback(...args)
14
+ })
15
+
16
+ handle = repeat ? api.setInterval(invoke, delay) : api.setTimeout(invoke, delay)
17
+ release = scope.track(() => {
18
+ if (repeat) api.clearInterval(handle)
19
+ else api.clearTimeout(handle)
20
+ })
21
+ if (!ref) handle?.unref?.()
22
+
23
+ return Object.freeze({
24
+ handle,
25
+ cancel() {
26
+ if (!active) return false
27
+ active = false
28
+ if (repeat) api.clearInterval(handle)
29
+ else api.clearTimeout(handle)
30
+ release()
31
+ return true
32
+ },
33
+ })
34
+ }
35
+
36
+ export function createRuntimeTimers(scope, api = globalThis) {
37
+ return Object.freeze({
38
+ timeout(callback, delay, options = {}) {
39
+ return createOwnedTimer(scope, callback, delay, false, api, options.ref !== false)
40
+ },
41
+ interval(callback, delay, options = {}) {
42
+ return createOwnedTimer(scope, callback, delay, true, api, options.ref !== false)
43
+ },
44
+ })
45
+ }
@@ -0,0 +1,69 @@
1
+ import LegacyOpenCodeLoopPlugin from "./legacy-v1.js"
2
+ import { createSessionRuntimeManager } from "./runtime/session-manager.js"
3
+ import { observeRuntimeEvent } from "./runtime/observer.js"
4
+
5
+ const clientGenerations = new WeakMap()
6
+
7
+ function reserveClientGeneration(client, directory) {
8
+ if (!client || typeof client !== "object") return () => true
9
+ let generations = clientGenerations.get(client)
10
+ if (!generations) {
11
+ generations = new Map()
12
+ clientGenerations.set(client, generations)
13
+ }
14
+ const key = String(directory || "")
15
+ const generation = (generations.get(key) || 0) + 1
16
+ generations.set(key, generation)
17
+ return () => generations.get(key) === generation
18
+ }
19
+
20
+ function scopedClient(client) {
21
+ if (!client || typeof client !== "object") return client
22
+ return new Proxy(client, {
23
+ get(target, property) {
24
+ const value = Reflect.get(target, property, target)
25
+ return typeof value === "function" ? value.bind(target) : value
26
+ },
27
+ })
28
+ }
29
+
30
+ function scheduleLegacyCleanup(dispose, isCurrentGeneration) {
31
+ for (const delay of [0, 25, 100, 500, 2_000, 10_000, 30_000]) {
32
+ const timer = setTimeout(() => {
33
+ if (!isCurrentGeneration()) return
34
+ Promise.resolve(dispose()).catch(() => {})
35
+ }, delay)
36
+ timer.unref?.()
37
+ }
38
+ }
39
+
40
+ export const OpenCodeLoopPlugin = async (input = {}) => {
41
+ const realClient = input?.client
42
+ const isCurrentGeneration = reserveClientGeneration(realClient, input?.directory)
43
+ const client = scopedClient(realClient)
44
+ const hooks = await LegacyOpenCodeLoopPlugin({ ...input, client })
45
+ const legacyDispose = typeof hooks?.dispose === "function" ? hooks.dispose.bind(hooks) : undefined
46
+ if (!legacyDispose) return hooks
47
+
48
+ const runtimeManager = createSessionRuntimeManager()
49
+ const legacyEvent = typeof hooks?.event === "function" ? hooks.event.bind(hooks) : undefined
50
+ let disposed = false
51
+ return {
52
+ ...hooks,
53
+ ...(legacyEvent ? {
54
+ event: async (payload) => {
55
+ observeRuntimeEvent(runtimeManager, payload)
56
+ return await legacyEvent(payload)
57
+ },
58
+ } : {}),
59
+ dispose: async () => {
60
+ if (disposed) return
61
+ disposed = true
62
+ try { runtimeManager.dispose("plugin-disposed") } catch {}
63
+ await legacyDispose()
64
+ scheduleLegacyCleanup(legacyDispose, isCurrentGeneration)
65
+ },
66
+ }
67
+ }
68
+
69
+ export default OpenCodeLoopPlugin