@xtruder/oc-plugin-convergence-engine 0.1.0

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 (5) hide show
  1. package/package.json +57 -0
  2. package/prompts.ts +164 -0
  3. package/server.ts +15 -0
  4. package/tui.tsx +784 -0
  5. package/types.ts +44 -0
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/package.json",
3
+ "name": "@xtruder/oc-plugin-convergence-engine",
4
+ "version": "0.1.0",
5
+ "description": "Convergence engine for OpenCode - automatically verifies and continues LLM work until tasks are truly complete",
6
+ "license": "MIT",
7
+ "type": "module",
8
+ "exports": {
9
+ "./server": {
10
+ "import": "./server.ts",
11
+ "config": {
12
+ "enabled": true,
13
+ "maxRounds": 5,
14
+ "autoStart": false
15
+ }
16
+ },
17
+ "./tui": {
18
+ "import": "./tui.tsx",
19
+ "config": {
20
+ "enabled": true,
21
+ "sidebar": true,
22
+ "maxRounds": 5
23
+ }
24
+ }
25
+ },
26
+ "files": [
27
+ "server.ts",
28
+ "tui.tsx",
29
+ "prompts.ts",
30
+ "types.ts"
31
+ ],
32
+ "engines": {
33
+ "opencode": ">=1.3.14"
34
+ },
35
+ "peerDependencies": {
36
+ "@opencode-ai/plugin": "*",
37
+ "@opentui/core": "*",
38
+ "@opentui/solid": "*",
39
+ "solid-js": "*"
40
+ },
41
+ "peerDependenciesMeta": {
42
+ "@opencode-ai/plugin": { "optional": true },
43
+ "@opentui/core": { "optional": true },
44
+ "@opentui/solid": { "optional": true },
45
+ "solid-js": { "optional": true }
46
+ },
47
+ "devDependencies": {
48
+ "@opencode-ai/plugin": "^1.3.0",
49
+ "@opentui/core": "*",
50
+ "@opentui/solid": "^0.1.96",
51
+ "bun-types": "^1.3.11",
52
+ "typescript": "^5.7.0"
53
+ },
54
+ "scripts": {
55
+ "typecheck": "tsc --noEmit"
56
+ }
57
+ }
package/prompts.ts ADDED
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Adversarial verification prompt sent to a SEPARATE verifier session.
3
+ * The verifier receives an LLM-generated summary (from a forked session)
4
+ * and must actively look for problems rather than confirm success.
5
+ */
6
+ export const VERIFIER_SYSTEM_PROMPT = `You are an independent code auditor and task verifier. Your job is to critically evaluate whether a task has been completed correctly and thoroughly.
7
+
8
+ You must be SKEPTICAL by default. Assume the work is incomplete until you can confirm otherwise through concrete evidence. Do not take the original assistant's claims at face value.
9
+
10
+ Your verification process:
11
+ 1. Read the original task requirements carefully
12
+ 2. Examine every file that was created or modified
13
+ 3. Look for: missing requirements, incomplete implementations, errors, edge cases not handled, tests not written, build/lint issues
14
+ 4. Run any available verification commands (tests, type checks, builds) to confirm correctness
15
+ 5. Check that the output matches what was actually requested, not just what's close enough
16
+
17
+ IMPORTANT: Use the TodoWrite tool to track your verification progress. Create a todo for each verification step you plan to perform and update their status as you work through them. This gives visibility into what you are checking and how far along you are.
18
+
19
+ You are NOT the assistant that did the work. You are the reviewer. Be thorough and honest.`
20
+
21
+ /**
22
+ * Prompt sent to a FORKED copy of the worker session to generate
23
+ * a structured summary of the work done. The fork has full conversation
24
+ * context, so the LLM can produce a proper summary.
25
+ *
26
+ * On round 1, produces a full summary. On subsequent rounds, receives the
27
+ * previous summary and only outputs what changed since then.
28
+ */
29
+ export function buildSummarizePrompt(previousSummary?: string): string {
30
+ if (!previousSummary) {
31
+ return `Summarize everything that happened in this session for an independent code reviewer. Be factual and specific. Include:
32
+
33
+ 1. **Original task**: What was the user's request?
34
+ 2. **Actions taken**: What files were created, modified, or deleted? What commands were run?
35
+ 3. **Current state**: What is the current state of the work? What exists on disk now?
36
+ 4. **Open issues**: Were there any errors, failed tool calls, or unresolved problems?
37
+ 5. **Completion claim**: Did you indicate the task was finished, or were you still working?
38
+
39
+ Be honest and include anything that went wrong or was left incomplete. Do not editorialize or justify -- just report the facts.`
40
+ }
41
+
42
+ return `An independent code reviewer already has the following summary of this session from a previous verification round:
43
+
44
+ <previous-summary>
45
+ ${previousSummary}
46
+ </previous-summary>
47
+
48
+ Your job is to produce a **delta summary** -- only describe what changed SINCE that summary was written. Focus on:
49
+
50
+ 1. **New actions**: What files were created, modified, or deleted since the previous summary? What new commands were run?
51
+ 2. **Fixes applied**: What issues from the previous round were addressed, and how?
52
+ 3. **Remaining issues**: Are there any new errors, failed tool calls, or unresolved problems?
53
+ 4. **Current state**: Has the overall state changed? Is the work now complete?
54
+
55
+ Do NOT repeat information already in the previous summary. Only report new or changed facts. Be concise and factual.`
56
+ }
57
+
58
+ /**
59
+ * The prompt sent to the verifier session for each verification round.
60
+ * Receives an LLM-generated summary from the forked session.
61
+ */
62
+ export function buildVerificationPrompt(input: {
63
+ summary: string
64
+ hasFailedTools: boolean
65
+ round: number
66
+ customInstructions?: string
67
+ isDelta?: boolean
68
+ }): string {
69
+ const parts: string[] = []
70
+
71
+ parts.push(`## Verification Round ${input.round}`)
72
+ parts.push("")
73
+ if (input.isDelta) {
74
+ parts.push(`### Changes Since Last Round`)
75
+ parts.push(`The following describes only what changed since the previous verification round:`)
76
+ } else {
77
+ parts.push(`### Session Summary`)
78
+ }
79
+ parts.push(input.summary)
80
+ parts.push("")
81
+
82
+ if (input.hasFailedTools) {
83
+ parts.push(
84
+ `**WARNING**: Some tool calls failed during the previous round. Pay special attention to whether the failures were recovered from.`,
85
+ )
86
+ parts.push("")
87
+ }
88
+
89
+ if (input.customInstructions) {
90
+ parts.push(`### Additional Verification Criteria`)
91
+ parts.push(input.customInstructions)
92
+ parts.push("")
93
+ }
94
+
95
+ parts.push(`### Your Task`)
96
+ parts.push(
97
+ `Verify whether the original task has been fully and correctly completed. Be adversarial -- actively look for problems, missing pieces, and things that could be wrong.`,
98
+ )
99
+ parts.push("")
100
+ parts.push(`Specifically check:`)
101
+ parts.push(`1. Are ALL requirements from the original task addressed?`)
102
+ parts.push(
103
+ `2. Were any files that should have been created/modified missed?`,
104
+ )
105
+ parts.push(`3. Are there syntax errors, type errors, or broken imports?`)
106
+ parts.push(
107
+ `4. If tests were expected, do they exist and would they pass?`,
108
+ )
109
+ parts.push(`5. Are there any obvious bugs or logic errors?`)
110
+ parts.push(
111
+ `6. Is the implementation complete or are there TODOs/placeholders left?`,
112
+ )
113
+ parts.push("")
114
+ parts.push(
115
+ `After your analysis, you MUST end your response with exactly one of:`,
116
+ )
117
+ parts.push("")
118
+ parts.push(
119
+ `[CONVERGENCE:COMPLETE] - ONLY if you found zero issues after thorough review`,
120
+ )
121
+ parts.push(
122
+ `[CONVERGENCE:ISSUES] - If you found ANY issues, followed by a numbered list of specific problems that need fixing`,
123
+ )
124
+
125
+ return parts.join("\n")
126
+ }
127
+
128
+ /**
129
+ * Prompt sent back to the ORIGINAL session when the verifier finds issues.
130
+ * Tells the worker to fix the specific problems identified.
131
+ */
132
+ export function buildFixPrompt(issues: string): string {
133
+ return `The convergence engine's independent verifier found the following issues with your work. Fix all of them:
134
+
135
+ ${issues}
136
+
137
+ After fixing everything, confirm what you fixed.`
138
+ }
139
+
140
+ /**
141
+ * Parse the verifier's response. The verifier uses [CONVERGENCE:COMPLETE]
142
+ * or [CONVERGENCE:ISSUES] markers.
143
+ */
144
+ export function parseVerifierResponse(text: string): {
145
+ complete: boolean
146
+ issues: string
147
+ } {
148
+ const completeMatch = text.includes("[CONVERGENCE:COMPLETE]")
149
+ const issuesMatch = text.includes("[CONVERGENCE:ISSUES]")
150
+
151
+ if (completeMatch && !issuesMatch) {
152
+ return { complete: true, issues: "" }
153
+ }
154
+
155
+ // Extract everything after [CONVERGENCE:ISSUES]
156
+ const issuesIdx = text.indexOf("[CONVERGENCE:ISSUES]")
157
+ if (issuesIdx !== -1) {
158
+ const issues = text.slice(issuesIdx + "[CONVERGENCE:ISSUES]".length).trim()
159
+ return { complete: false, issues }
160
+ }
161
+
162
+ // No marker found -- treat as incomplete with the whole response as context
163
+ return { complete: false, issues: text }
164
+ }
package/server.ts ADDED
@@ -0,0 +1,15 @@
1
+ import type { Plugin, PluginModule } from "@opencode-ai/plugin"
2
+
3
+ /**
4
+ * Server plugin stub. The convergence engine runs entirely in the TUI plugin.
5
+ * This file exists to satisfy the package.json exports for ./server.
6
+ */
7
+ const server: Plugin = async () => {
8
+ return {}
9
+ }
10
+
11
+ /** The convergence engine server plugin module (stub -- logic is in TUI). */
12
+ export default {
13
+ id: "convergence-engine",
14
+ server,
15
+ } satisfies PluginModule & { id: string }
package/tui.tsx ADDED
@@ -0,0 +1,784 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui"
3
+ import { createSignal, Show, For } from "solid-js"
4
+ import { readFileSync } from "node:fs"
5
+ import { resolve } from "node:path"
6
+ import { TextAttributes } from "@opentui/core"
7
+ import type { ConvergenceKVData, VerifierIssue, ConvergenceOptions } from "./types"
8
+ import { DEFAULT_OPTIONS } from "./types"
9
+ import {
10
+ VERIFIER_SYSTEM_PROMPT,
11
+ buildSummarizePrompt,
12
+ buildVerificationPrompt,
13
+ buildFixPrompt,
14
+ parseVerifierResponse,
15
+ } from "./prompts"
16
+
17
+ const KV_PREFIX = "convergence:"
18
+
19
+ /** Minimal type for message objects returned by the SDK. */
20
+ interface SessionMessage {
21
+ info: { role: "user" | "assistant" }
22
+ parts: Array<{ type: string; text?: string; status?: string }>
23
+ }
24
+
25
+ interface ConvergenceDisplayState {
26
+ active: boolean
27
+ round: number
28
+ maxRounds: number
29
+ status: string
30
+ verifierSessionID?: string
31
+ issues: VerifierIssue[]
32
+ startedAt: number
33
+ processing: boolean
34
+ lastSummary?: string
35
+ }
36
+
37
+ /** Parse issue lines from the verifier's response. */
38
+ function parseIssueLines(issuesText: string): string[] {
39
+ const lines = issuesText.split("\n").filter((l) => l.trim())
40
+ const issues: string[] = []
41
+ let current = ""
42
+ for (const line of lines) {
43
+ if (/^\d+[\.\)]\s/.test(line.trim())) {
44
+ if (current) issues.push(current.trim())
45
+ current = line.trim().replace(/^\d+[\.\)]\s*/, "")
46
+ } else {
47
+ current += " " + line.trim()
48
+ }
49
+ }
50
+ if (current) issues.push(current.trim())
51
+ return issues.length > 0 ? issues : [issuesText.trim()]
52
+ }
53
+
54
+ const tui: TuiPlugin = async (api, options) => {
55
+ const opts: ConvergenceOptions = {
56
+ ...DEFAULT_OPTIONS,
57
+ ...(options ?? {}),
58
+ } as ConvergenceOptions
59
+
60
+ const [state, setState] = createSignal<ConvergenceDisplayState | null>(null)
61
+ const [dialogOpen, setDialogOpen] = createSignal(false)
62
+
63
+ // Session todos from the coding session (tracked via todo.updated events)
64
+ interface SessionTodo { content: string; status: string; priority: string }
65
+ const [sessionTodos, setSessionTodos] = createSignal<SessionTodo[]>([])
66
+
67
+ // Track managed session IDs (forks, verifiers) to ignore their idle events
68
+ const managedSessions = new Set<string>()
69
+
70
+ // Abort controller for the current verification run
71
+ const [currentAbort, setCurrentAbort] = createSignal<AbortController | null>(null)
72
+
73
+ // Track whether the coding session completed normally (went busy then idle)
74
+ // vs was aborted. We only trigger verification after normal completion.
75
+ const [sessionWentBusy, setSessionWentBusy] = createSignal(false)
76
+ const [sessionAborted, setSessionAborted] = createSignal(false)
77
+
78
+ // Idle event resolvers for promptAndWait
79
+ const idleResolvers = new Map<string, () => void>()
80
+
81
+ /** Abort any in-flight verification/summarization and clean up. */
82
+ function abortVerification(): void {
83
+ const ac = currentAbort()
84
+ if (ac) {
85
+ ac.abort()
86
+ setCurrentAbort(null)
87
+ }
88
+ // Abort running LLM in all managed sessions (forks + verifier)
89
+ for (const sid of managedSessions) {
90
+ api.client.session.abort({ sessionID: sid }).catch(() => {})
91
+ }
92
+ idleResolvers.clear()
93
+ setSessionTodos([])
94
+ }
95
+
96
+ // Load custom verification instructions
97
+ let customVerificationMd: string | undefined
98
+ try {
99
+ const dir = api.state.path.directory
100
+ customVerificationMd = readFileSync(resolve(dir, ".opencode", "verification.md"), "utf-8")
101
+ } catch { /* no custom instructions */ }
102
+
103
+ // --- KV helpers ---
104
+
105
+ function getKV(sessionID: string): ConvergenceKVData | undefined {
106
+ if (!api.kv.ready) return undefined
107
+ return api.kv.get<ConvergenceKVData | undefined>(`${KV_PREFIX}${sessionID}`, undefined)
108
+ }
109
+
110
+ function setKV(sessionID: string, data: ConvergenceKVData): void {
111
+ if (!api.kv.ready) return
112
+ api.kv.set(`${KV_PREFIX}${sessionID}`, data)
113
+ }
114
+
115
+ function clearKV(sessionID: string): void {
116
+ if (!api.kv.ready) return
117
+ api.kv.set(`${KV_PREFIX}${sessionID}`, undefined)
118
+ }
119
+
120
+ // --- Dialog ---
121
+
122
+ function showVerifierDialog(): void {
123
+ // Always re-render the dialog to reflect status changes.
124
+ // If user dismissed with Esc (dialogOpen=false), reopen it.
125
+ setDialogOpen(true)
126
+
127
+ api.ui.dialog.replace(
128
+ () => {
129
+ const s = state()
130
+ if (!s) return <text>No convergence data</text>
131
+
132
+ const statusLabel = (): string => {
133
+ switch (s.status) {
134
+ case "summarizing": return "Summarizing session..."
135
+ case "verifying": return "Verifier reviewing your work..."
136
+ case "sending-fix": return "Sending fixes..."
137
+ default: return "Starting verification..."
138
+ }
139
+ }
140
+
141
+ const unresolvedIssues = (): VerifierIssue[] =>
142
+ s.issues.filter((i) => !i.resolved)
143
+
144
+ return (
145
+ <box flexDirection="column" paddingLeft={1} paddingRight={1} paddingTop={1} paddingBottom={1}>
146
+ <text attributes={TextAttributes.BOLD} fg={api.theme.current.accent}>
147
+ {"~ Convergence Engine ~"}
148
+ </text>
149
+ <text fg={api.theme.current.warning ?? api.theme.current.accent}>
150
+ {` ${statusLabel()}`}
151
+ </text>
152
+ <text fg={api.theme.current.textMuted}>
153
+ {` Round ${s.round}/${s.maxRounds}`}
154
+ </text>
155
+ <Show when={unresolvedIssues().length > 0}>
156
+ <box marginTop={1} flexDirection="column">
157
+ <text attributes={TextAttributes.BOLD} fg={api.theme.current.error ?? api.theme.current.accent}>
158
+ {` Issues from previous round (${unresolvedIssues().length}):`}
159
+ </text>
160
+ <For each={unresolvedIssues().slice(0, 10)}>
161
+ {(issue) => (
162
+ <text fg={api.theme.current.text}>
163
+ {` [ ] ${issue.text.slice(0, 70)}`}
164
+ </text>
165
+ )}
166
+ </For>
167
+ </box>
168
+ </Show>
169
+ <Show when={sessionTodos().length > 0}>
170
+ {(() => {
171
+ const MAX_VISIBLE = 8
172
+ const todos = sessionTodos()
173
+ const completed = todos.filter((t) => t.status === "completed").length
174
+ const active = todos.filter((t) => t.status === "in_progress" || t.status === "pending")
175
+ const done = todos.filter((t) => t.status === "completed" || t.status === "cancelled")
176
+ // Show active todos first, then fill remaining slots with recent completed ones
177
+ const visible = [
178
+ ...active,
179
+ ...done.slice(-(MAX_VISIBLE - Math.min(active.length, MAX_VISIBLE))),
180
+ ].slice(0, MAX_VISIBLE)
181
+ return (
182
+ <box marginTop={1} flexDirection="column">
183
+ <text fg={api.theme.current.accent}>
184
+ {` Verification tasks (${completed}/${todos.length} done):`}
185
+ </text>
186
+ <For each={visible}>
187
+ {(todo) => {
188
+ const icon = todo.status === "completed" ? "x"
189
+ : todo.status === "in_progress" ? "~"
190
+ : todo.status === "cancelled" ? "-"
191
+ : " "
192
+ const color = todo.status === "completed" ? api.theme.current.textMuted
193
+ : todo.status === "in_progress" ? (api.theme.current.warning ?? api.theme.current.accent)
194
+ : api.theme.current.text
195
+ return (
196
+ <text fg={color}>
197
+ {` [${icon}] ${todo.content.slice(0, 65)}`}
198
+ </text>
199
+ )
200
+ }}
201
+ </For>
202
+ <Show when={todos.length > MAX_VISIBLE && active.length > MAX_VISIBLE}>
203
+ <text fg={api.theme.current.textMuted}>
204
+ {` ... and ${active.length - MAX_VISIBLE} more pending`}
205
+ </text>
206
+ </Show>
207
+ </box>
208
+ )
209
+ })()}
210
+ </Show>
211
+ <text fg={api.theme.current.textMuted} marginTop={1}>
212
+ {" Press Esc to dismiss (verification continues)."}
213
+ </text>
214
+ </box>
215
+ )
216
+ },
217
+ () => setDialogOpen(false),
218
+ )
219
+ }
220
+
221
+ function hideVerifierDialog(): void {
222
+ api.ui.dialog.clear()
223
+ setDialogOpen(false)
224
+ }
225
+
226
+ function currentSessionID(): string | undefined {
227
+ const route = api.route.current
228
+ if (route.name === "session") {
229
+ return (route.params as Record<string, string>)?.sessionID
230
+ }
231
+ return undefined
232
+ }
233
+
234
+ function updateState(partial: Partial<ConvergenceDisplayState>): void {
235
+ const s = state()
236
+ if (!s) return
237
+ const updated = { ...s, ...partial }
238
+ setState(updated)
239
+ // Re-render dialog when status changes during active verification
240
+ if (partial.status && updated.processing) {
241
+ showVerifierDialog()
242
+ }
243
+ }
244
+
245
+ // --- promptAndWait: send prompt, wait for session.idle, read response ---
246
+
247
+ async function promptAndWait(
248
+ targetSessionID: string,
249
+ body: Record<string, unknown>,
250
+ ac: AbortController,
251
+ timeoutMs = 600_000,
252
+ ): Promise<string> {
253
+ idleResolvers.delete(targetSessionID)
254
+
255
+ const idlePromise = new Promise<void>((resolve, reject) => {
256
+ idleResolvers.set(targetSessionID, resolve)
257
+ ac.signal.addEventListener("abort", () => {
258
+ api.client.session.abort({ sessionID: targetSessionID }).catch(() => {})
259
+ reject(new Error("verification cancelled"))
260
+ }, { once: true })
261
+ })
262
+
263
+ await api.client.session.promptAsync({
264
+ sessionID: targetSessionID,
265
+ ...(body as Record<string, unknown>),
266
+ })
267
+
268
+ let timer: ReturnType<typeof setTimeout> | undefined
269
+ const timeoutPromise = new Promise<void>((_, reject) => {
270
+ timer = setTimeout(() => reject(new Error("promptAndWait timed out")), timeoutMs)
271
+ })
272
+
273
+ try {
274
+ await Promise.race([idlePromise, timeoutPromise])
275
+ } finally {
276
+ clearTimeout(timer)
277
+ idleResolvers.delete(targetSessionID)
278
+ }
279
+
280
+ const messagesResp = await api.client.session.messages({
281
+ sessionID: targetSessionID,
282
+ limit: 5,
283
+ })
284
+ const messages = (messagesResp.data ?? []) as Array<SessionMessage>
285
+ const lastAssistant = [...messages]
286
+ .reverse()
287
+ .find((m) => m.info?.role === "assistant")
288
+
289
+ if (!lastAssistant) return ""
290
+ return lastAssistant.parts
291
+ .filter((p) => p.type === "text")
292
+ .map((p) => p.text ?? "")
293
+ .join("\n")
294
+ }
295
+
296
+ // --- Verification flow ---
297
+
298
+ async function runVerification(sessionID: string): Promise<void> {
299
+ const s = state()
300
+ if (!s || !s.active || s.processing) return
301
+
302
+ const round = s.round + 1
303
+ if (round > s.maxRounds) {
304
+ setState(null)
305
+ hideVerifierDialog()
306
+ api.ui.toast({ message: `Max rounds (${s.maxRounds}) reached`, variant: "warning" })
307
+ return
308
+ }
309
+
310
+ const ac = new AbortController()
311
+ setCurrentAbort(ac)
312
+ let vsID: string | undefined = s.verifierSessionID
313
+ let cancelled = false
314
+
315
+ updateState({ round, status: "summarizing", processing: true })
316
+ setSessionTodos([])
317
+ showVerifierDialog()
318
+
319
+ try {
320
+ // Step 1: Fork and summarize
321
+ const forkResp = await api.client.session.fork({ sessionID })
322
+ const forkID = (forkResp.data as { id?: string })?.id
323
+ if (!forkID) {
324
+ setState(null)
325
+ hideVerifierDialog()
326
+ api.ui.toast({ message: "Convergence error: Failed to fork session", variant: "error" })
327
+ return
328
+ }
329
+ managedSessions.add(forkID)
330
+
331
+ let summaryText: string
332
+ try {
333
+ const summarizePrompt = buildSummarizePrompt(s.lastSummary)
334
+ summaryText = await promptAndWait(forkID, {
335
+ parts: [{ type: "text" as const, text: summarizePrompt, synthetic: true }],
336
+ }, ac)
337
+ } finally {
338
+ managedSessions.delete(forkID)
339
+ try { await api.client.session.delete({ sessionID: forkID }) } catch { /* ok */ }
340
+ }
341
+
342
+ if (ac.signal.aborted) { cancelled = true; return }
343
+ if (!summaryText) summaryText = "(summary generation produced no output)"
344
+
345
+ // Accumulate summaries so subsequent rounds retain full context
346
+ const cumulativeSummary = s.lastSummary
347
+ ? `${s.lastSummary}\n\n--- Round ${round} Delta ---\n${summaryText}`
348
+ : summaryText
349
+ updateState({ lastSummary: cumulativeSummary })
350
+
351
+ // Step 2: Create or reuse verifier session
352
+ if (!vsID) {
353
+ let codingTitle = "session"
354
+ try {
355
+ const sessionResp = await api.client.session.get({ sessionID })
356
+ codingTitle = (sessionResp.data as { title?: string })?.title ?? "session"
357
+ } catch { /* use default */ }
358
+
359
+ const verifierResp = await api.client.session.create({
360
+ title: `[Verifier] ${codingTitle}`,
361
+ })
362
+ vsID = (verifierResp.data as { id?: string })?.id
363
+ if (!vsID) {
364
+ setState(null)
365
+ hideVerifierDialog()
366
+ api.ui.toast({ message: "Convergence error: Failed to create verifier session", variant: "error" })
367
+ return
368
+ }
369
+ updateState({ verifierSessionID: vsID })
370
+ }
371
+ managedSessions.add(vsID)
372
+
373
+ // Step 3: Send to verifier
374
+ updateState({ status: "verifying" })
375
+
376
+ const messagesResp = await api.client.session.messages({
377
+ sessionID,
378
+ limit: 3,
379
+ })
380
+ const recentMessages = (messagesResp.data ?? []) as Array<SessionMessage>
381
+ const lastAssistant = [...recentMessages]
382
+ .reverse()
383
+ .find((m) => m.info.role === "assistant")
384
+ const hasFailedTools =
385
+ lastAssistant?.parts?.some(
386
+ (p) => p.type === "tool" && p.status === "error",
387
+ ) ?? false
388
+
389
+ const verificationPrompt = buildVerificationPrompt({
390
+ summary: summaryText,
391
+ hasFailedTools,
392
+ round,
393
+ customInstructions: customVerificationMd,
394
+ isDelta: !!s.lastSummary,
395
+ })
396
+
397
+ // Only send the system prompt on the first round; the verifier session
398
+ // is persistent and retains it across subsequent messages
399
+ const verifierBody: Record<string, unknown> = {
400
+ parts: [{ type: "text" as const, text: verificationPrompt }],
401
+ }
402
+ if (!s.lastSummary) {
403
+ verifierBody.system = VERIFIER_SYSTEM_PROMPT
404
+ }
405
+
406
+ const responseText = await promptAndWait(vsID, verifierBody, ac)
407
+
408
+ if (ac.signal.aborted) { cancelled = true; return }
409
+
410
+ if (!responseText) {
411
+ setState(null)
412
+ hideVerifierDialog()
413
+ api.ui.toast({ message: "Convergence error: Verifier returned empty response", variant: "error" })
414
+ return
415
+ }
416
+
417
+ const result = parseVerifierResponse(responseText)
418
+
419
+ if (result.complete) {
420
+ const resolved = s.issues.map((i) => ({ ...i, resolved: true }))
421
+ if (vsID) {
422
+ setKV(sessionID, { verifierSessionID: vsID, round, status: "converged", issues: resolved, lastSummary: state()?.lastSummary })
423
+ }
424
+ // Reset round counter for the next convergence run but keep active
425
+ updateState({ round: 0, status: "idle", processing: false, issues: resolved, lastSummary: undefined })
426
+ hideVerifierDialog()
427
+ api.ui.toast({ message: `Convergence achieved after ${round} round${round > 1 ? "s" : ""}!`, variant: "success" })
428
+ } else {
429
+ // Verifier found issues
430
+ const issueLines = parseIssueLines(result.issues)
431
+ const newIssues: VerifierIssue[] = issueLines.map((text) => ({ round, text, resolved: false }))
432
+ const allIssues = [...s.issues, ...newIssues]
433
+
434
+ updateState({ status: "sending-fix", issues: allIssues })
435
+
436
+ if (vsID) {
437
+ setKV(sessionID, { verifierSessionID: vsID, round, status: "sending-fix", issues: allIssues, lastSummary: state()?.lastSummary })
438
+ }
439
+
440
+ const fixPrompt = buildFixPrompt(result.issues)
441
+ hideVerifierDialog()
442
+
443
+ const issueCount = issueLines.length
444
+ api.ui.toast({
445
+ message: `Verifier found ${issueCount} issue${issueCount > 1 ? "s" : ""} -- sending fixes`,
446
+ variant: "warning",
447
+ })
448
+
449
+ // Send fix prompt -- triggers LLM response
450
+ await api.client.session.promptAsync({
451
+ sessionID,
452
+ parts: [{ type: "text" as const, text: fixPrompt, synthetic: true }],
453
+ })
454
+
455
+ updateState({ status: "waiting-for-fix", processing: false })
456
+ }
457
+ } catch (err) {
458
+ // Only real errors reach here (network failures, unexpected exceptions)
459
+ // Cancellation is handled via the `cancelled` flag and early returns
460
+ if (ac.signal.aborted) {
461
+ cancelled = true
462
+ } else {
463
+ setState(null)
464
+ hideVerifierDialog()
465
+ api.ui.toast({ message: `Convergence error: ${String(err)}`, variant: "error" })
466
+ }
467
+ } finally {
468
+ setCurrentAbort(null)
469
+ if (vsID) managedSessions.delete(vsID)
470
+ if (cancelled) {
471
+ // Roll back round and reset to idle
472
+ updateState({ status: "idle", processing: false, round: Math.max(0, round - 1) })
473
+ hideVerifierDialog()
474
+ api.ui.toast({ message: "Verification cancelled", variant: "info" })
475
+ } else {
476
+ const s = state()
477
+ if (s?.processing) updateState({ processing: false })
478
+ }
479
+ }
480
+ }
481
+
482
+ // --- Event handlers ---
483
+
484
+ // session.idle: resolve promptAndWait OR trigger verification
485
+ const offIdle = api.event.on("session.idle", async (event) => {
486
+ const sessionID = (event as { properties?: { sessionID?: string } }).properties?.sessionID
487
+ if (!sessionID) return
488
+
489
+ // Resolve promptAndWait for managed sessions (fork/verifier)
490
+ const resolver = idleResolvers.get(sessionID)
491
+ if (resolver) {
492
+ idleResolvers.delete(sessionID)
493
+ resolver()
494
+ return
495
+ }
496
+
497
+ // Ignore idle events from managed sessions
498
+ if (managedSessions.has(sessionID)) return
499
+
500
+ // Trigger verification for the current session
501
+ if (sessionID !== currentSessionID()) return
502
+
503
+ // Only trigger verification if the session completed normally (went busy
504
+ // then idle without being aborted). Reset tracking state either way.
505
+ const wasBusy = sessionWentBusy()
506
+ const wasAborted = sessionAborted()
507
+ setSessionWentBusy(false)
508
+ setSessionAborted(false)
509
+
510
+ if (!wasBusy || wasAborted) return
511
+
512
+ const s = state()
513
+ if (!s || !s.active || s.processing) return
514
+
515
+ await runVerification(sessionID)
516
+ })
517
+ api.lifecycle.onDispose(offIdle)
518
+
519
+ // session.status: track busy state for the coding session and cancel
520
+ // verification if the user submits a new prompt while it's running
521
+ const offStatus = api.event.on("session.status", (event) => {
522
+ const props = (event as { properties?: { sessionID?: string; status?: { type?: string } } }).properties
523
+ if (props?.sessionID !== currentSessionID()) return
524
+
525
+ if (props?.status?.type === "busy") {
526
+ setSessionWentBusy(true)
527
+ setSessionAborted(false)
528
+ hideVerifierDialog()
529
+
530
+ const s = state()
531
+ if (s?.processing) {
532
+ abortVerification()
533
+ }
534
+ }
535
+ })
536
+ api.lifecycle.onDispose(offStatus)
537
+
538
+ // session.error: mark session as aborted so the next idle is skipped
539
+ const offError = api.event.on("session.error", (event) => {
540
+ const props = (event as { properties?: { sessionID?: string; error?: { name?: string; type?: string } } }).properties
541
+ if (!props?.sessionID || props.sessionID !== currentSessionID()) return
542
+
543
+ const errorName = props.error?.name ?? props.error?.type
544
+ if (errorName === "MessageAbortedError" || errorName === "message_aborted") {
545
+ setSessionAborted(true)
546
+ abortVerification()
547
+ }
548
+ })
549
+ api.lifecycle.onDispose(offError)
550
+
551
+ // todo.updated: track todos from managed sessions (fork/verifier)
552
+ const offTodo = api.event.on("todo.updated", (event) => {
553
+ const props = (event as { properties?: { sessionID?: string; todos?: SessionTodo[] } }).properties
554
+ if (!props?.sessionID) return
555
+ const s = state()
556
+ if (!s?.active || props.sessionID !== s.verifierSessionID) return
557
+ setSessionTodos(props.todos ?? [])
558
+ // Re-render dialog if it's open to show updated todos
559
+ if (dialogOpen()) showVerifierDialog()
560
+ })
561
+ api.lifecycle.onDispose(offTodo)
562
+
563
+ /** Fetch current todos for a session via the API. */
564
+ async function fetchSessionTodos(sessionID: string): Promise<void> {
565
+ try {
566
+ const resp = await api.client.session.todo({ sessionID })
567
+ setSessionTodos((resp.data ?? []) as SessionTodo[])
568
+ } catch {
569
+ setSessionTodos([])
570
+ }
571
+ }
572
+
573
+ // --- Commands ---
574
+
575
+ api.command.register(() => [
576
+ {
577
+ title: "/converge - Toggle convergence engine",
578
+ value: "convergence-engine.converge",
579
+ category: "Convergence",
580
+ slash: { name: "converge" },
581
+ async onSelect() {
582
+ const sessionID = currentSessionID()
583
+ if (!sessionID) {
584
+ api.ui.toast({ message: "No active session", variant: "warning" })
585
+ return
586
+ }
587
+
588
+ const current = state()
589
+ if (current?.active) {
590
+ abortVerification()
591
+ setState(null)
592
+ hideVerifierDialog()
593
+ api.ui.toast({ message: "Convergence engine stopped", variant: "info" })
594
+ return
595
+ }
596
+
597
+ const kvData = getKV(sessionID)
598
+ if (kvData?.verifierSessionID) {
599
+ const openCount = kvData.issues.filter((i: VerifierIssue) => !i.resolved).length
600
+ api.ui.dialog.replace(() => (
601
+ <api.ui.DialogSelect
602
+ title="Previous verifier session found"
603
+ options={[
604
+ {
605
+ title: "Resume convergence",
606
+ value: "resume",
607
+ description: `Reuse verifier session (${kvData.round} previous round${kvData.round > 1 ? "s" : ""}, ${openCount} open issue${openCount !== 1 ? "s" : ""})`,
608
+ },
609
+ {
610
+ title: "Start fresh",
611
+ value: "reset",
612
+ description: "Delete old verifier and start new convergence",
613
+ },
614
+ { title: "Cancel", value: "cancel" },
615
+ ]}
616
+ onSelect={async (option: { value: string }) => {
617
+ api.ui.dialog.clear()
618
+ if (option.value === "resume") {
619
+ managedSessions.add(kvData.verifierSessionID)
620
+ setState({
621
+ active: true, round: 0, maxRounds: opts.maxRounds,
622
+ status: "idle", verifierSessionID: kvData.verifierSessionID,
623
+ issues: kvData.issues, startedAt: Date.now(), processing: false,
624
+ lastSummary: kvData.lastSummary,
625
+ })
626
+ // Hydrate verifier todos from the resumed session
627
+ fetchSessionTodos(kvData.verifierSessionID)
628
+ api.ui.toast({ message: "Convergence resumed", variant: "success" })
629
+ } else if (option.value === "reset") {
630
+ try { await api.client.session.delete({ sessionID: kvData.verifierSessionID }) } catch { /* ok */ }
631
+ clearKV(sessionID)
632
+ setState({
633
+ active: true, round: 0, maxRounds: opts.maxRounds,
634
+ status: "idle", issues: [], startedAt: Date.now(), processing: false,
635
+ })
636
+ api.ui.toast({ message: "Convergence engine activated", variant: "success" })
637
+ }
638
+ }}
639
+ />
640
+ ))
641
+ return
642
+ }
643
+
644
+ setState({
645
+ active: true, round: 0, maxRounds: opts.maxRounds,
646
+ status: "idle", issues: [], startedAt: Date.now(), processing: false,
647
+ })
648
+ api.ui.toast({ message: "Convergence engine activated", variant: "success" })
649
+ },
650
+ },
651
+ {
652
+ title: "/converge-status - Show convergence progress",
653
+ value: "convergence-engine.status",
654
+ category: "Convergence",
655
+ slash: { name: "converge-status" },
656
+ onSelect() {
657
+ const s = state()
658
+ if (!s) {
659
+ api.ui.toast({ message: "Convergence not active", variant: "info" })
660
+ return
661
+ }
662
+ api.ui.dialog.replace(() => {
663
+ const unresolved = (): VerifierIssue[] => s.issues.filter((i) => !i.resolved)
664
+ const resolved = (): VerifierIssue[] => s.issues.filter((i) => i.resolved)
665
+ return (
666
+ <box flexDirection="column" paddingLeft={1} paddingRight={1} paddingTop={1} paddingBottom={1}>
667
+ <text attributes={TextAttributes.BOLD} fg={api.theme.current.accent}>{"~ Convergence Status ~"}</text>
668
+ <text fg={api.theme.current.text}>
669
+ {` Status: ${s.status} | Round ${s.round}/${s.maxRounds}`}
670
+ </text>
671
+ <Show when={s.verifierSessionID}>
672
+ <text fg={api.theme.current.textMuted}>{` Verifier: ${s.verifierSessionID}`}</text>
673
+ </Show>
674
+ <Show when={unresolved().length > 0}>
675
+ <box marginTop={1} flexDirection="column">
676
+ <text attributes={TextAttributes.BOLD} fg={api.theme.current.error ?? api.theme.current.accent}>
677
+ {` Open (${unresolved().length}):`}
678
+ </text>
679
+ <For each={unresolved()}>
680
+ {(issue) => (
681
+ <text fg={api.theme.current.text}>{` [ ] R${issue.round}: ${issue.text.slice(0, 70)}`}</text>
682
+ )}
683
+ </For>
684
+ </box>
685
+ </Show>
686
+ <Show when={resolved().length > 0}>
687
+ <box marginTop={1} flexDirection="column">
688
+ <text fg={api.theme.current.textMuted}>{` Resolved (${resolved().length}):`}</text>
689
+ <For each={resolved().slice(-5)}>
690
+ {(issue) => (
691
+ <text fg={api.theme.current.textMuted}>{` [x] R${issue.round}: ${issue.text.slice(0, 70)}`}</text>
692
+ )}
693
+ </For>
694
+ </box>
695
+ </Show>
696
+ </box>
697
+ )
698
+ })
699
+ },
700
+ },
701
+ {
702
+ title: "/converge-reset - Reset verifier session",
703
+ value: "convergence-engine.reset",
704
+ category: "Convergence",
705
+ slash: { name: "converge-reset" },
706
+ async onSelect() {
707
+ const sessionID = currentSessionID()
708
+ if (!sessionID) {
709
+ api.ui.toast({ message: "No active session", variant: "warning" })
710
+ return
711
+ }
712
+ abortVerification()
713
+ const s = state()
714
+ if (s?.verifierSessionID) {
715
+ try { await api.client.session.delete({ sessionID: s.verifierSessionID }) } catch { /* ok */ }
716
+ }
717
+ const kvData = getKV(sessionID)
718
+ if (kvData?.verifierSessionID && kvData.verifierSessionID !== s?.verifierSessionID) {
719
+ try { await api.client.session.delete({ sessionID: kvData.verifierSessionID }) } catch { /* ok */ }
720
+ }
721
+ clearKV(sessionID)
722
+ setState(null)
723
+ hideVerifierDialog()
724
+ api.ui.toast({ message: "Verifier session reset", variant: "info" })
725
+ },
726
+ },
727
+ ])
728
+
729
+ // --- Sidebar: convergence status ---
730
+
731
+ api.slots.register({
732
+ order: 90,
733
+ slots: {
734
+ sidebar_content(ctx) {
735
+ const statusColor = (): string => {
736
+ const s = state()
737
+ switch (s?.status) {
738
+ case "summarizing": case "verifying": case "sending-fix": case "waiting-for-fix":
739
+ return ctx.theme.current.warning.toString() ?? ctx.theme.current.accent.toString()
740
+ default: return ctx.theme.current.text.toString()
741
+ }
742
+ }
743
+ const label = (): string => {
744
+ const s = state()
745
+ if (!s) return ""
746
+ const icon = (() => {
747
+ switch (s.status) {
748
+ case "summarizing": case "verifying": return "~"
749
+ case "sending-fix": case "waiting-for-fix": return ">"
750
+ default: return "-"
751
+ }
752
+ })()
753
+ const unresolvedCount = s.issues.filter((i) => !i.resolved).length
754
+ const parts = [`[${icon}] Convergence R${s.round}/${s.maxRounds}`]
755
+ if (unresolvedCount > 0) parts.push(`${unresolvedCount} issues`)
756
+ const todos = sessionTodos()
757
+ if (todos.length > 0) {
758
+ const done = todos.filter((t) => t.status === "completed").length
759
+ const inProgress = todos.find((t) => t.status === "in_progress")
760
+ if (inProgress) {
761
+ parts.push(inProgress.content.slice(0, 30))
762
+ } else {
763
+ parts.push(`${done}/${todos.length} tasks`)
764
+ }
765
+ }
766
+ return parts.join(" | ")
767
+ }
768
+ return (
769
+ <Show when={state()}>
770
+ <box paddingLeft={1}>
771
+ <text fg={statusColor()}>{label()}</text>
772
+ </box>
773
+ </Show>
774
+ )
775
+ },
776
+ },
777
+ })
778
+ }
779
+
780
+ /** The convergence engine TUI plugin module. */
781
+ export default {
782
+ id: "convergence-engine",
783
+ tui,
784
+ } satisfies TuiPluginModule & { id: string }
package/types.ts ADDED
@@ -0,0 +1,44 @@
1
+ /** Possible status values for the convergence engine state machine. */
2
+ export type ConvergenceStatus =
3
+ | "idle"
4
+ | "summarizing"
5
+ | "verifying"
6
+ | "sending-fix"
7
+ | "waiting-for-fix"
8
+ | "converged"
9
+ | "max-rounds"
10
+ | "error"
11
+
12
+ /** A single issue found by the verifier during a verification round. */
13
+ export interface VerifierIssue {
14
+ /** The round in which this issue was found. */
15
+ round: number
16
+ /** Human-readable description of the issue. */
17
+ text: string
18
+ /** Whether this issue has been resolved in a subsequent round. */
19
+ resolved: boolean
20
+ }
21
+
22
+ /** Configuration options for the convergence engine. */
23
+ export interface ConvergenceOptions {
24
+ enabled: boolean
25
+ maxRounds: number
26
+ autoStart: boolean
27
+ verificationPrompt?: string
28
+ }
29
+
30
+ /** Default convergence options used when no user overrides are provided. */
31
+ export const DEFAULT_OPTIONS: ConvergenceOptions = {
32
+ enabled: true,
33
+ maxRounds: 5,
34
+ autoStart: false,
35
+ }
36
+
37
+ /** Shape of the data stored in TUI KV for a convergence mapping. */
38
+ export interface ConvergenceKVData {
39
+ verifierSessionID: string
40
+ round: number
41
+ status: ConvergenceStatus
42
+ issues: VerifierIssue[]
43
+ lastSummary?: string
44
+ }