@rrr2010/opencode-roundtable 0.2.0 → 0.3.2

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/docs/SPEC.md CHANGED
@@ -1,979 +1,974 @@
1
- # Roundtable Plugin — Technical Spec
2
-
3
- OpenCode plugin that orchestrates **multi-agent round-robin debates** with
4
- isolated sessions, shared context between debaters, a built-in observer
5
- (with override), and extension support.
6
-
7
- ---
8
-
9
- ## Table of Contents
10
-
11
- - [1. Philosophy](#1-philosophy)
12
- - [2. Architecture](#2-architecture)
13
- - [3. Tool API](#3-tool-api)
14
- - [4. Lifecycle](#4-lifecycle)
15
- - [5. States](#5-states)
16
- - [6. History & Context](#6-history--context)
17
- - [7. Edge Case Handling](#7-edge-case-handling)
18
- - [8. Observer](#8-observer)
19
- - [9. Extend Mode](#9-extend-mode)
20
- - [10. Visual Interface](#10-visual-interface)
21
- - [11. Plugin Structure](#11-plugin-structure)
22
- - [12. Tests & Validation](#12-tests--validation)
23
- - [13. Glossary](#13-glossary)
24
- - [14. References](#14-references)
25
-
26
- ---
27
-
28
- ## 1. Philosophy
29
-
30
- A roundtable simulates a **panel discussion** where experts from different
31
- areas debate a topic. Each participant keeps their own personality (system
32
- prompt, tools, temperature, color) but shares the same discussion history.
33
-
34
- ### Principles
35
-
36
- 1. **Clean slate on entry** — the roundtable session starts fresh, without
37
- baggage from the original conversation.
38
- 2. **Shared context between debaters** — everyone sees the same discussion
39
- history, including previous tool calls and outputs.
40
- 3. **Isolated personalities** — each agent keeps its own system prompt,
41
- tools, and configuration. No "personality merging".
42
- 4. **Separate orchestrator** — whoever invokes the roundtable does not
43
- participate; they only receive the result at the end.
44
- 5. **Default observer** — the plugin has a built-in observer that always
45
- consolidates the debate at the end. The orchestrator may override it
46
- with a specific agent.
47
- 6. **Extensible** — roundtables can be continued with additional rounds.
48
-
49
- ---
50
-
51
- ## 2. Architecture
52
-
53
- ```
54
- ┌──────────────────────────────────────────────────────────────────┐
55
- │ MAIN SESSION (S1) │
56
- ┌────────────────────────────────────────────────────────────┐
57
- build/plan: roundtable({agents, prompt, rounds, observer?})
58
- └────────────────────────────────────────────────────────────┘
59
-
60
- │ │ plugin creates
61
-
62
- ┌────────────────────────────────────────────────────────────┐
63
- ROUNDTABLE SESSION (S2) — parentID: S1
64
- │ │ │ │
65
- │ │ [noReply] Debate rules / serialized state │ │
66
- │ │ [agent:PM] Round 1 PM responds │ │
67
- │ │ [agent:DEV] Round 1 — DEV responds (sees history) │ │
68
- │ │ [agent:RV] Round 1 — RV responds │ │
69
- │ │ ... N rounds ... │ │
70
- │ │ [observer] Consolidated summary │ │
71
- │ │ [noReply] ━━━ Roundtable Concluded ━━━ (delimiter) │ │
72
- │ │ │ │
73
- └────────────────────────────────────────────────────────────┘
74
-
75
- │ │ plugin injects summary
76
-
77
- [noReply] Consolidated result
78
- └──────────────────────────────────────────────────────────────────┘
79
- ```
80
-
81
- ### Session relationship
82
-
83
- - **S1 (Main):** where the user interacts. The session that calls `roundtable()`.
84
- - **S2 (Roundtable):** created via `session.create({ parentID: S1 })`. Appears
85
- as a child session in navigation (`<Leader>+Right/Left`).
86
- - The plugin listens to events from BOTH: `session.idle` on S2 for sequencing,
87
- `session.deleted` on S1 or S2 for cleanup.
88
-
89
- ### Execution model (event-driven)
90
-
91
- The OpenCode V2 server already operates **asynchronously by design**:
92
-
93
- 1. `session.prompt()` returns immediately with an `Admitted` record
94
- 2. The agent execution loop runs in the background
95
- 3. When processing finishes, a `session.idle { sessionID }` event is emitted
96
- 4. The plugin listens to these events to advance the round-robin
97
-
98
- This makes the roundtable **native to OpenCode's model** — no polling or
99
- blocking required.
100
-
101
- ### Why `session.create()` and not `session.fork()`?
102
-
103
- `fork()` copies ALL history from the current session, including tool calls,
104
- user conversation, etc. This pollutes the roundtable context.
105
-
106
- `create()` starts from scratch. The plugin builds the debate history manually,
107
- including only what matters.
108
-
109
- ---
110
-
111
- ## 3. Tool API
112
-
113
- ### Roundtable tool
114
-
115
- ```typescript
116
- roundtable({
117
- agents?: string[], // Names in speaking order (min 2). Required for new debates.
118
- prompt: string, // Topic/challenge. For multi-round, include per-round instructions.
119
- rounds?: number, // Complete rounds (default: 1, max: 50)
120
- observer?: string, // Agent for final consolidation (default: built-in)
121
- sessionID?: string, // ses_xxxx — pass to extend a concluded roundtable
122
- title?: string, // Custom title (auto-generated if omitted)
123
- })
124
- ```
125
-
126
- **Returns:** `{ sessionID: string, summary: string }` after the debate concludes.
127
-
128
- **Side effect:** injects system-level prompts into each agent's context during the debate (role-setting, topic, turn routing, and lifecycle signals). The `[ROUNDTABLE META]` block is internal routing data — agents should ignore it for analysis purposes.
129
-
130
- ### Parameters
131
-
132
- | Parameter | Type | Required | Default | Description |
133
- |-----------|------|----------|---------|-------------|
134
- | `agents` | `string[]` | No* | — | Agent names in speaking order (min 2, schema `minItems:2`). Required for new debates; omit when extending |
135
- | `prompt` | `string` | Yes | — | Topic/challenge. For multi-round, include per-round instructions. All agents see the full agenda |
136
- | `rounds` | `number` | No | `1` | Number of complete rounds (schema `minimum:1`, `maximum:50`) |
137
- | `observer` | `string` | No | `built-in` | Agent name for final consolidation. Observer does not debate it summarizes after all rounds |
138
- | `sessionID` | `string` | No* | — | Session ID (format: `ses_xxxx`). Pass to extend a concluded roundtable. Omit (and pass `agents`) for a fresh debate |
139
- | `title` | `string` | No | Auto | Custom title (max 200 chars). Auto-generated as `"(Roundtable) - {first 80 chars of prompt, truncated at word boundary}"` if omitted |
140
-
141
- ### Available agents tool
142
-
143
- A complementary tool lets the orchestrator discover which agents exist:
144
-
145
- ```typescript
146
- available_agents()
147
- ```
148
-
149
- **Returns** a formatted list of configured agent names:
150
-
151
- ```
152
- Available agents: pm, dev, rv, plan, build
153
- ```
154
-
155
- This helps the orchestrator (e.g., `build`) know which names to pass in
156
- `agents` when calling `roundtable()`.
157
-
158
- The tool infers the operation mode from the presence of `sessionID`:
159
- - **No `sessionID`** → starts a fresh debate (requires `agents`)
160
- - **`sessionID` present** → continues a concluded roundtable (uses stored agent config)
161
-
162
- ### Usage examples
163
-
164
- ```typescript
165
- // Basic: 1 round, built-in observer (default)
166
- roundtable({
167
- agents: ["pm", "dev", "rv"],
168
- prompt: "What architecture should we use for the payments module?",
169
- })
170
-
171
- // Explicit observer and 2 rounds
172
- roundtable({
173
- agents: ["pm", "dev"],
174
- prompt: "Should we migrate to microservices?",
175
- rounds: 2,
176
- observer: "rv", // uses agent "rv" as observer
177
- })
178
-
179
- // Built-in observer with custom title
180
- roundtable({
181
- agents: ["pm", "dev"],
182
- prompt: "Which ORM should we use?",
183
- title: "Debate: ORM vs Raw SQL",
184
- })
185
-
186
- // Extending a previous roundtable (no agents uses original config)
187
- roundtable({
188
- sessionID: "abc123",
189
- rounds: 2,
190
- prompt: "Dive deeper into operational costs",
191
- })
192
-
193
- // Discovering agents before calling roundtable
194
- // Orchestrator can call available_agents() first, then:
195
- roundtable({
196
- agents: ["pm", "dev"], // now it knows these names exist
197
- prompt: "...",
198
- })
199
- ```
200
-
201
- ### Return value (tool execute)
202
-
203
- The tool blocks until the debate concludes and returns an object with:
204
-
205
- ```typescript
206
- {
207
- sessionID: string, // S2 session ID — use for extend mode
208
- summary: string // Consolidated executive summary from the observer
209
- }
210
- ```
211
-
212
- If an error occurs (invalid agents, session not found, etc.) the tool returns
213
- an error string prefixed with `"Error:"`. The tool never hangs error returns
214
- are immediate.
215
-
216
- ---
217
-
218
- ## 4. Lifecycle
219
-
220
- ### Full flow (fresh debate)
221
-
222
- ```
223
- PHASE 1 — INITIALIZATION
224
- ─────────────────────────
225
- 1. Orchestrator agent (e.g., build) calls roundtable()
226
- 2. Plugin validates agents against ctx.client.app.agents()
227
- 3. Plugin creates S2 via session.create({ parentID: S1, title })
228
- 4. Plugin serializes initial state as noReply in S2
229
- 5. Plugin sends initial prompt to agents[0] via session.prompt({agent})
230
- (Prompt returns immediately LLM runs in background)
231
- 6. roundtable() returns confirmation
232
- 7. session.idle fires on S1 (no active S1 state → ignored)
233
-
234
- PHASE 2 DEBATE (repeats for each round)
235
- ──────────────────────────────────────────
236
- 8. agents[i] finishes session.idle fires on S2
237
- 9. Plugin reads response with session.messages(S2)
238
- 10. Plugin accumulates into internal history[] (text + tool outputs)
239
- 11. Plugin decides next step:
240
- a) Still have agents in this round? → agents[i+1]
241
- b) Round complete + more rounds left? → agents[0], round++
242
- c) All rounds complete? PHASE 3 (observer always runs)
243
-
244
- PHASE 3 OBSERVER
245
- ──────────────────
246
- 12. Plugin decides which observer to use:
247
- a) Explicit observer? sends full history to that agent
248
- b) Default observer? sends to S2 with built-in plugin prompt
249
- 13. Observer consolidatessession.idle
250
- 14. Plugin reads summary → PHASE 4
251
-
252
- PHASE 4 FINALIZATION
253
- ──────────────────────
254
- 15. Plugin extracts consolidated summary
255
- 16. Plugin injects into S1 via session.prompt({noReply: true})
256
- 17. Plugin re-serializes final state into S2 (phase: done, full history)
257
- replaces the stale initial meta so extend mode finds correct state
258
- 18. Plugin injects delimiter noReply in S2:
259
- "━━━ Roundtable Concluded ━━━
260
- Messages below this line are not part of the original debate.
261
- The result was consolidated in the main session."
262
- 19. Plugin updates S2 title: "Roundtable: A vs B · CONCLUDED"
263
- 20. Plugin shows toast "Roundtable concluded"
264
- 21. Plugin clears in-memory state for S2
265
- ```
266
-
267
- ### Flow (extend)
268
-
269
- ```
270
- 1. Orchestrator calls roundtable({sessionID, rounds, prompt})
271
- 2. Plugin fetches serialized state from S2 (from the noReply tag)
272
- 3. Plugin restores history + config of the original roundtable
273
- 4. Adds new rounds to totalRounds (accumulative)
274
- 5. Sends continuation prompt to agents[0]
275
- 6. Returns to PHASE 2 of the normal flow
276
- ```
277
-
278
- ---
279
-
280
- ## 5. States
281
-
282
- ### Roundtable state machine
283
-
284
- ```
285
- ┌────────────┐
286
- PENDING │ (initial state, before S2 creation)
287
- └─────┬──────┘
288
- session.create() + prompt[0]
289
-
290
- ┌────────────┐
291
- ACTIVE │ (debate in progress)
292
- └─────┬──────┘
293
- all rounds complete
294
-
295
- ┌────────────┐
296
- │ OBSERVING │ (observer — built-in or explicit — always runs)
297
- └─────┬──────┘
298
- │ observer summarizes
299
-
300
- ┌────────────┐
301
- │ DONE │
302
- └────────────┘
303
-
304
- At any state:
305
- │ session.deleted
306
-
307
- ┌────────────┐
308
- ABORTED │
309
- └────────────┘
310
- ```
311
-
312
- > **Note**: unlike the previous spec, `OBSERVING` **always** runs.
313
- > The built-in default observer guarantees every debate produces a
314
- > consolidated summary at the end.
315
-
316
- ### In-memory state type
317
-
318
- ```typescript
319
- interface RoundtableState {
320
- // Identification
321
- sessionID: string // S2
322
- parentSessionID: string // S1
323
-
324
- // Config
325
- agents: string[]
326
- totalRounds: number
327
- observer: "built-in" | string // "built-in" = plugin default observer
328
- prompt: string
329
-
330
- // Progress
331
- currentRound: number // 0-indexed
332
- currentAgentIndex: number // 0-indexed
333
- phase: "active" | "observing" | "done" | "aborted"
334
-
335
- // Data
336
- history: HistoryEntry[]
337
- errors: string[]
338
- createdAt: number
339
- }
340
-
341
- interface HistoryEntry {
342
- agent: string
343
- round: number
344
- response: string // plain text of the response
345
- toolCalls: ToolCallSummary[] // tool names + output previews
346
- hasError: boolean
347
- }
348
-
349
- interface ToolCallSummary {
350
- toolName: string
351
- outputPreview: string // first 500 chars of output, or "error"
352
- }
353
- ```
354
-
355
- ### Serialization in S2
356
-
357
- State is serialized into a `noReply` message at the start of S2 to survive
358
- restarts and enable the `extend` mode. The tag includes an "IGNORE FOR
359
- ANALYSIS" note so agents know this is internal routing data, not part of
360
- the debate topic:
361
-
362
- ```
363
- [ROUNDTABLE META — INTERNAL ORCHESTRATION DATA — IGNORE FOR ANALYSIS]
364
- {"sessionID":"S2","agents":["pm","dev"],"totalRounds":2,
365
- "observer":"built-in","prompt":"...","currentRound":1,
366
- "currentAgentIndex":0,"phase":"active","history":[...]}
367
- [/ROUNDTABLE META]
368
- ```
369
-
370
- The state is re-serialized when the debate concludes (`finalizeRoundtable`),
371
- overwriting the initial meta with `phase: "done"` and the full history.
372
- This ensures extend mode always reads the most recent state.
373
-
374
- The parsing functions (`findSerializedState`, `deserializeState`) search
375
- for the last occurrence of the `[ROUNDTABLE META` prefix to handle both
376
- old and new format, and to prefer the newest state when multiple metas
377
- exist in the same session.
378
-
379
- ---
380
-
381
- ## 6. History & Context
382
-
383
- ### Content sent to each agent
384
-
385
- Each time the plugin passes the turn (via `buildAgentPrompt`), it sends a
386
- minimal message with:
387
-
388
- ```
389
- [System] You are participating in a roundtable debate. Agents (A → B → C) will discuss the topic below in N round(s).
390
- [System] The [ROUNDTABLE META] block above is internal routing data — ignore it for all analysis. Focus only on the topic given in [Topic] below.
391
- [Topic] {prompt}
392
-
393
- Round N/M | Current agent: Z
394
-
395
- [System] This is the last prompt — finalize your thoughts (last round only)
396
- ```
397
-
398
- The `[ROUNDTABLE META]` block is injected separately as a `noReply` user
399
- message. It contains orchestration state (session ID, agents, rounds,
400
- phase, history, errors, timestamps) and is marked as internal agents
401
- are instructed to ignore it for analysis purposes.
402
-
403
- **History is NOT currently injected into the agent prompt** — the agent
404
- only sees its current turn context. History accumulation happens server-side
405
- in the plugin state and is used by the observer for consolidation. History
406
- injection into subsequent rounds is a planned enhancement.
407
-
408
- ### What the plugin includes in history
409
-
410
- The accumulated history contains **everything relevant for the next debater**:
411
-
412
- - `TextPart.text` — agent's textual response
413
- - `ToolCallSummary` — tool name + **output preview** (first 500 characters).
414
- If DEV ran `ls src/`, the next agent sees the discovered directory structure.
415
- - `FilePart` — file references (name only, not full content)
416
-
417
- ### What the plugin does NOT include in history
418
-
419
- - `ReasoningPart.text` agent's internal reasoning (stays in S2 session, but
420
- not injected into the next agent's prompt text; the next agent can browse
421
- S2 to see it)
422
- - `StepStartPart` / `StepFinishPart` — execution metadata
423
-
424
- ### Why include tool outputs in the prompt text?
425
-
426
- Debaters need to see **what was discovered**, not just what was said.
427
- If DEV ran `ls src/` and found the project structure, PM should see that
428
- finding to base their argument on it. This is why tool outputs enter the
429
- textual summary the plugin builds (500-char preview).
430
-
431
- What the plugin controls is the format: outputs are included as part of each
432
- agent's discussion block, not as standalone tool calls.
433
-
434
- ---
435
-
436
- ## 7. Edge Case Handling
437
-
438
- ### 7.1 Agent failure (provider error)
439
-
440
- ```
441
- session.error on S2 with agent = currentAgent
442
- Logs error in state.errors[]
443
- → Skips to next agent
444
- Toast: "DEV failed on Round 2. Skipping to RV."
445
- If all agents fail aborts with error
446
- ```
447
-
448
- ### 7.2 User closes S2 (session.deleted)
449
-
450
- ```
451
- session.deleted on S2
452
- phase = "aborted"
453
- state.history still has partial responses
454
- → Injects into S1:
455
- "[Roundtable interrupted — session closed.
456
- Partial history up to interruption:]
457
- {partial history}"
458
- → Toast: "Roundtable interrupted"
459
- Clears state
460
- ```
461
-
462
- ### 7.3 User closes S1 (session.deleted on S1)
463
-
464
- ```
465
- session.deleted on S1 === state.parentSessionID
466
- Aborts S2 via session.abort({path:{id:S2}})
467
- → Clears state
468
- ```
469
-
470
- ### 7.4 User types a message in S2 during the debate
471
-
472
- S2 is a visible and interactive session. If the user switches to S2
473
- and types something:
474
-
475
- - The message enters S2's context
476
- - If the current agent is still processing, the user's message
477
- will be processed after the agent finishes
478
- - The plugin continues the round-robin — the user's message becomes
479
- part of the history
480
- - The plugin cannot detect "who" sent the message (there is no
481
- `trigger` field in the API)
482
-
483
- **Behavior**: natural and desired. The user can intervene in the debate.
484
-
485
- ### 7.5 Debate loop (agents repeating arguments)
486
-
487
- After each round, the plugin checks similarity between the latest response
488
- and previous ones using **Jaccard similarity of bigrams (character pairs)**:
489
-
490
- ```
491
- tokens1 = set(bigrams(current_text))
492
- tokens2 = set(bigrams(previous_text))
493
- similarity = |tokens1 ∩ tokens2| / |tokens1 ∪ tokens2|
494
-
495
- if similarity > 0.85 (LOOP_SIMILARITY_THRESHOLD):
496
- "Roundtable ended due to repetition agents reached an impasse"
497
- Injects partial result into S1
498
- ```
499
-
500
- **Why Jaccard bigrams?**
501
- - Purely computational (no LLM calls or embeddings needed)
502
- - Zero external dependencies
503
- - Reasonable for detecting textual argument repetition
504
- - Threshold 0.85 is conservative adjustable via constant
505
-
506
- **Considerations**: the algorithm operates on cleaned text (without template
507
- formatting). Comparison is between the last response of the current round
508
- and the last response of the previous round, not all combinations.
509
-
510
- ### 7.6 Agent timeout
511
-
512
- - Each agent turn has an implicit **5-minute** timeout
513
- - Implemented via AbortController + setTimeout
514
- - If it expires: `session.abort({path:{id:S2}})` skip to next agent
515
-
516
- ### 7.7 Multiple simultaneous roundtables
517
-
518
- Each roundtable has its own state in `Map<sessionID, RoundtableState>`.
519
- They are independent. The plugin allows multiple roundtables running in
520
- parallel.
521
-
522
- ### 7.8 Volatile state (OpenCode restart)
523
-
524
- If OpenCode restarts mid-roundtable:
525
- - In-memory state is lost
526
- - S2 session remains in the database
527
- - `noReply` messages with serialized state persist in S2
528
-
529
- **Recovery**: on initialization, the plugin runs `scanOrphanRoundtables()`:
530
- ```
531
- 1. List all sessions via session.list()
532
- 2. For each session, search for messages tagged [ROUNDTABLE META]
533
- 3. If found:
534
- a) Check if parent S1 still exists
535
- b) If S1 exists → notify: "Roundtable #abc123 was interrupted by
536
- restart. Use mode:'extend' to continue."
537
- c) If S1 does not exist abort S2 and clean up
538
- ```
539
-
540
- ### 7.9 Compaction during a roundtable
541
-
542
- - The plugin does NOT trigger compaction automatically
543
- - If the user or system compacts S2, `noReply` messages with serialized
544
- state survive (they are user messages in the session)
545
- - The plugin uses `experimental.session.compacting` to re-inject critical
546
- state into the compaction prompt
547
-
548
- ### 7.10 Human message in S2 after conclusion
549
-
550
- After `phase = "done"` (roundtable concluded):
551
- - The plugin has already injected the `━━━ Roundtable Concluded ━━━`
552
- delimiter in S2
553
- - The plugin stops listening to S2 events
554
- - If the user types in S2, OpenCode responds normally with the session's
555
- default agent **the plugin no longer interferes**
556
- - The response **does not** alter the result already consolidated in S1
557
-
558
- ```
559
- [noReply] ━━━ Roundtable Concluded ━━━
560
- Messages below this line are not part of the original debate.
561
- The result was consolidated in the main session.
562
- ── You ──
563
- (conversation can continue here, but the plugin no longer
564
- manages this session.)
565
- ```
566
-
567
- **Behavior**: natural and transparent. S2 becomes a regular session after
568
- the roundtable concludes.
569
-
570
- ---
571
-
572
- ## 8. Observer
573
-
574
- ### Function
575
-
576
- The observer **always consolidates** the debate at the end. It does not
577
- participate in rounds it enters after all rounds are complete to produce
578
- an executive summary.
579
-
580
- ### Default observer (built-in)
581
-
582
- If the orchestrator does not specify `observer`, the plugin uses a built-in
583
- observer with the following prompt:
584
-
585
- ```
586
- DEFAULT_OBSERVER_PROMPT = `
587
- You are an impartial roundtable observer.
588
- Consolidate the debate below into:
589
-
590
- 1. **Executive summary** (2-3 sentences)
591
- 2. **Key points** raised by each participant
592
- 3. **Decisions or convergences** reached
593
- 4. **Remaining open questions**
594
- 5. **Suggested next steps**
595
-
596
- Debate:
597
- {full_history}
598
- `
599
- ```
600
-
601
- This prompt is sent to the S2 session itself (no specific agent), using the
602
- session's default model/provider. The plugin extracts the textual response
603
- as the final summary.
604
-
605
- ### Explicit observer (override)
606
-
607
- If the orchestrator passes `observer: "rv"`, the plugin:
608
- 1. Builds a similar prompt, but with `Your role: rv. Provide an executive summary...`
609
- 2. Sends via `session.prompt({ agent: "rv" })` on the S2 session
610
- 3. The "rv" agent responds with its own tools and personality
611
-
612
- ### Flow
613
-
614
- ```
615
- 1. All rounds complete → PHASE 3
616
- 2. Plugin decides:
617
- a) Explicit observer? sends to that agent
618
- b) Default observer? → sends DEFAULT_OBSERVER_PROMPT to S2
619
- 3. Observer responds → session.idle
620
- 4. Plugin extracts text → injects into S1 as final result
621
- 5. Plugin injects delimiter noReply into S2
622
- ```
623
-
624
- ---
625
-
626
- ## 9. Extend Mode
627
-
628
- ### Usage
629
-
630
- ```typescript
631
- roundtable({
632
- sessionID: "abc123", // Original S2 ID
633
- rounds: 2,
634
- prompt: "Dive deeper into operational costs", // new prompt
635
- })
636
- ```
637
-
638
- ### Prompt semantics in extend mode
639
-
640
- The `prompt` parameter in `extend` mode can be:
641
-
642
- | Intent | Example | Effect |
643
- |--------|---------|--------|
644
- | **Continuation** | "Debate more about X" | New prompt is sent as a complement to the original topic. `final_prompt = "Original topic: {original.prompt}\n\nContinuation: {prompt}"` |
645
- | **New topic** | "Now plan Y" | New prompt replaces the topic, but previous history is preserved as context. `final_prompt = "Previous discussion history:\n{history}\n\nNew challenge: {prompt}"` |
646
-
647
- The plugin infers intent heuristically: if the prompt starts with
648
- "Debate more", "Continue", "Dive deeper" → continuation. Otherwise
649
- → new topic. The behavior can be refined during implementation.
650
-
651
- ### Flow
652
-
653
- ```
654
- 1. Plugin fetches S2 by sessionID
655
- 2. Reads S2 → session.messages(S2)
656
- 3. Finds [ROUNDTABLE META ...] tag → deserializes state
657
- 4a. If phase === "done": proceed
658
- 4b. If phase === "active": check if actually running
659
- → Count assistant messages in S2 vs expected (totalRounds × agents + observer)
660
- → If assistantCount >= expected: session is stuck → auto-recover phase to "done"
661
- Else: return error "still active"
662
- 5. Creates NEW state with:
663
- - agents, observer, prompt from ORIGINAL
664
- - rounds += new_rounds (accumulative)
665
- - history = restored previous history (or empty if missing)
666
- - extended_prompt = inferred intent (continuation or new topic)
667
- 6. Sets phase = "active"
668
- 7. Sends extended_prompt to agents[0]
669
- 8. Continues normal PHASE 2 flow
670
- ```
671
-
672
- ### Stuck session recovery
673
-
674
- If an extend is interrupted mid-way (TUI freeze, process kill, etc.), the
675
- new serialized state in S2 has `phase: "active"` but no agent is running.
676
- The next extend call detects this by comparing the actual assistant message
677
- count against the expected count — if the session appears complete, it
678
- auto-resets to `phase: "done"` and proceeds normally. A log entry is
679
- emitted with `level: "info"` when recovery occurs.
680
-
681
- ### Constraints
682
-
683
- - Only works if S2 still exists (was not deleted)
684
- - Only works if original phase was "done"
685
- - Agents and observer must be the same as the original roundtable
686
- - Previous discussion history is always preserved
687
- - If the original roundtable used an explicit observer, the extend also uses
688
- it (observer type is stored in serialized state)
689
-
690
- ---
691
-
692
- ## 10. Visual Interface
693
-
694
- ### In the OpenCode TUI
695
-
696
- | Element | How it works |
697
- |---------|--------------|
698
- | Agent colors | Native OpenCode via `agent.color` in config |
699
- | Navigation | S2 is a child of S1 → `<Leader>+Right/Left` to switch |
700
- | Toast | Plugin shows toast on start, completion, and errors |
701
- | S2 title | Default: `"(Roundtable) - {prompt[:57]}..."`. Stays stable during debate. On conclusion: appends ` · CONCLUDED` |
702
- | Message names | Each response is tagged with the speaking agent's name |
703
-
704
- ### TUI appearance example
705
-
706
- ```
707
- ┌─────────────────────────────────────────────────┐
708
- │ Main Session (S1)
709
- ├─────────────────────────────────────────────────┤
710
- │ You: roundtable({agents:[pm,dev], rounds:2})
711
- │ │
712
- │ Build: Roundtable started in child session │
713
- │ #abc123 │
714
- │ │
715
- │ [noReply] ── Roundtable Concluded ── │
716
- │ Topic: What architecture? │
717
- │ PM: microservices... │
718
- │ DEV: monolith... │
719
- │ Conclusion: modular monolith │
720
- └─────────────────────────────────────────────────┘
721
-
722
- ┌─────────────────────────────────────────────────┐
723
- │ Roundtable Session #abc123 (child) │
724
- ├─────────────────────────────────────────────────┤
725
- [noReply] Debate rules... │
726
- │ ── PM ── │
727
- │ The microservices approach allows... │
728
- │ ── DEV ── │
729
- │ Disagree, the complexity is not justified... │
730
- │ ── PM (R2) ── │
731
- │ OK, but what if we split only the... │
732
- │ ── DEV (R2) ── │
733
- │ That's basically a modular monolith... │
734
- │ [noReply] ━━━ Roundtable Concluded ━━━ │
735
- └─────────────────────────────────────────────────┘
736
- ```
737
-
738
- ### Recommended color config
739
-
740
- ```json
741
- {
742
- "agent": {
743
- "pm": { "color": "#3498db" },
744
- "dev": { "color": "#2ecc71" },
745
- "rv": { "color": "#e74c3c" },
746
- "plan": { "color": "#f39c12" },
747
- "build": { "color": "#9b59b6" }
748
- }
749
- }
750
- ```
751
-
752
- ---
753
-
754
- ## 11. Plugin Structure
755
-
756
- ### Files (local install)
757
-
758
- ```
759
- ~/.config/opencode/plugins/
760
- └── roundtable.ts # Single-file plugin (dev)
761
- ```
762
-
763
- ### Files (npm package — planned)
764
-
765
- ```
766
- opencode-roundtable/
767
- ├── package.json # name, version, main: dist/index.js, files: ["dist"]
768
- ├── tsconfig.json # Compiles src/ → dist/
769
- ├── src/
770
- │ ├── index.ts # Plugin export (entry point)
771
- │ ├── types.ts # Interfaces: RoundtableState, HistoryEntry, ToolCallSummary, etc.
772
- │ ├── config.ts # loadConfig, validateConfig, defaults
773
- │ ├── state.ts # Module-level maps + serializeState / deserializeState
774
- │ ├── prompts.ts # buildAgentPrompt, buildObserverPrompt
775
- │ ├── events.ts # Event handlers (processNextTurn, handleAgentError, handleSessionDeleted)
776
- │ ├── extend.ts # extendRoundtable + helpers
777
- │ ├── roundtable-core.ts # startNewRoundtable, sendToAgent, finalizeRoundtable
778
- │ └── utils.ts # detectLoop, extractResponse, generateDefaultTitle, etc.
779
- ├── docs/
780
- │ ├── SPEC.md
781
- │ └── roundtable.schema.json
782
- └── README.md
783
- ```
784
-
785
- ### Dependencies
786
-
787
- None external. Only `@opencode-ai/plugin` (peer dependency of OpenCode).
788
-
789
- ### Code structure
790
-
791
- ```typescript
792
- // roundtable.ts
793
- import { type Plugin, tool } from "@opencode-ai/plugin"
794
-
795
- // == Types ==
796
- interface RoundtableState { ... }
797
- interface HistoryEntry { ... }
798
- interface ToolCallSummary { ... }
799
- type Phase = "active" | "observing" | "done" | "aborted"
800
-
801
- // == Constants ==
802
- const AGENT_TIMEOUT_MS = 300_000 // 5 min
803
- const LOOP_SIMILARITY_THRESHOLD = 0.85 // Jaccard bigrams
804
- const TOOL_OUTPUT_PREVIEW_MAX = 500 // tool output preview chars
805
- const DEFAULT_OBSERVER_PROMPT = `You are an impartial roundtable observer...`
806
-
807
- // == Plugin ==
808
- export const RoundtablePlugin: Plugin = async (ctx) => {
809
- const states = new Map<string, RoundtableState>()
810
-
811
- // Init: scan for orphan roundtables (see 7.8)
812
- scanOrphanRoundtables(ctx, states)
813
-
814
- return {
815
- event: async ({ event }) => {
816
- if (!event.properties?.sessionID) return
817
- // session.idle processNextTurn()
818
- // session.error → handleAgentError()
819
- // session.deleted → handleSessionDeleted()
820
- },
821
-
822
- "experimental.session.compacting": async (input, output) => {
823
- // Re-inject critical state during compaction
824
- },
825
-
826
- tool: {
827
- roundtable: tool({
828
- description: "Starts a multi-agent roundtable debate. Agents take turns discussing a topic.",
829
- args: {
830
- agents: tool.schema.array(tool.schema.string()).min(2),
831
- prompt: tool.schema.string(),
832
- rounds: tool.schema.number().min(1).default(1),
833
- observer: tool.schema.string().optional(),
834
- sessionID: tool.schema.string().optional(),
835
- title: tool.schema.string().optional(),
836
- },
837
- async execute(args, toolCtx) {
838
- if (args.sessionID) return extendRoundtable(ctx, args, toolCtx, states)
839
- return startNewRoundtable(ctx, args, toolCtx, states)
840
- },
841
- }),
842
-
843
- available_agents: tool({
844
- description: "Lists all configured agents that can participate in a roundtable.",
845
- args: {},
846
- async execute(_args, toolCtx) {
847
- const agents = await ctx.client.app.agents()
848
- return `Available agents: ${agents.map(a => a.name).join(", ")}`
849
- },
850
- }),
851
- },
852
- }
853
- }
854
-
855
- // == Validation ==
856
- async function validateAgents(ctx, agentNames: string[]): Promise<ValidationResult> {
857
- // 1. Calls ctx.client.app.agents() → gets real agent list
858
- // 2. Checks each name in agentNames
859
- // 3. Checks for duplicates
860
- // 4. Checks min 2 agents
861
- // 5. Returns { valid, available, errors }
862
- }
863
-
864
- // == Helper functions ==
865
- async function startNewRoundtable(...) { ... }
866
- async function extendRoundtable(...) { ... }
867
- async function processNextTurn(...) { ... }
868
- async function sendToAgent(...) { ... }
869
- async function finalizeRoundtable(...) { ... }
870
- async function handleAgentError(...) { ... }
871
- async function handleSessionDeleted(...) { ... }
872
- function buildAgentPrompt(...): string { ... }
873
- function buildObserverPrompt(...): string { ... }
874
- function extractResponse(...): string | null { ... }
875
- function detectLoop(...): boolean { ... } // Jaccard bigrams
876
- function buildToolSummary(...): ToolCallSummary { ... }
877
- function injectRoundtableDelimiter(...) { ... }
878
- function scanOrphanRoundtables(...) { ... }
879
- function generateDefaultTitle(...): string { ... }
880
- function serializeState(...): string { ... }
881
- function deserializeState(...): RoundtableState { ... }
882
- ```
883
-
884
- ### Hooks used
885
-
886
- | Hook | Purpose |
887
- |------|---------|
888
- | `event` | Listens to `session.idle`, `session.error`, `session.deleted` |
889
- | `experimental.session.compacting` | Preserves state during compaction |
890
- | `tool` | Defines the `roundtable` and `available_agents` tools |
891
-
892
- ### SDK APIs used
893
-
894
- | API | Usage |
895
- |-----|-------|
896
- | `ctx.client.session.create()` | Create S2 |
897
- | `ctx.client.session.prompt()` | Send messages to agents |
898
- | `ctx.client.session.messages()` | Read agent responses |
899
- | `ctx.client.session.abort()` | Abort timed-out agent |
900
- | `ctx.client.session.update()` | Update session title |
901
- | `ctx.client.tui.showToast()` | Notify user |
902
- | `ctx.client.app.agents()` | Discover available agents |
903
-
904
- ---
905
-
906
- ## 12. Tests & Validation
907
-
908
- ### Test scenarios
909
-
910
- 1. **Basic roundtable**: 2 agents, 1 round, default observer
911
- 2. **Explicit observer**: 3 agents, 2 rounds, observer="rv" consolidates
912
- 3. **Default observer**: same scenario, no observer param → uses built-in
913
- 4. **Agent failure**: provider error skip continue
914
- 5. **Timeout**: agent takes too long → abort + skip
915
- 6. **Loop detection**: agents repeat arguments → Jaccard > 0.85 → end debate
916
- 7. **Session deleted**: close S2 → partial result in S1
917
- 8. **Extend (continuation)**: conclude extend with "Debate more about X"
918
- 9. **Extend (new topic)**: conclude extend with "Now plan Y"
919
- 10. **Multiple**: 2 roundtables in parallel
920
- 11. **Compaction**: compact S2 during debate state preserved
921
- 12. **User interjection**: user writes in S2 during debate → continues
922
- 13. **Post-conclusion**: user writes in S2 after done S2 becomes normal session
923
- 14. **Startup recovery**: restart during ACTIVE scanOrphanRoundtables detects
924
- 15. **Invalid agent**: agents with nonexistent name error with available list
925
- 16. **Available agents tool**: orchestrator calls `available_agents()` gets list
926
-
927
- ### Acceptance criteria
928
-
929
- - [ ] `roundtable` tool appears in the agent's tool list
930
- - [ ] `available_agents` tool appears in the agent's tool list
931
- - [ ] S2 is created as a child of S1
932
- - [ ] Agents speak in the specified order
933
- - [ ] Each agent keeps its own personality
934
- - [ ] History + tool outputs are sent to each turn
935
- - [ ] Default observer (built-in) consolidates the debate at the end
936
- - [ ] Explicit observer (named agent) also works
937
- - [ ] Final result is injected into S1
938
- - [ ] Toast notifications appear on start, error, and conclusion
939
- - [ ] Agent errors are skipped with notification
940
- - [ ] Extend mode resumes from where it stopped (continuation and new topic)
941
- - [ ] Session deletion is handled gracefully
942
- - [ ] Loop detection works (Jaccard bigrams, threshold 0.85)
943
- - [ ] Invalid agent names return an error with the available list
944
- - [ ] `━━━ Roundtable Concluded ━━━` delimiter appears in S2 at the end
945
- - [ ] Post-conclusion messages in S2 do not affect the S1 result
946
- - [ ] Startup recovery detects and notifies about orphan roundtables
947
-
948
- ---
949
-
950
- ## 13. Glossary
951
-
952
- | Term | Definition |
953
- |------|------------|
954
- | **S1** | Main session, where the user interacts |
955
- | **S2** | Child session, where the roundtable runs |
956
- | **Round** | One round = all agents speak once |
957
- | **Turn** | A specific agent's turn to speak |
958
- | **Observer** | Agent that does not debate, only summarizes |
959
- | **Orchestrator** | Agent that called `roundtable()` |
960
- | **History** | Accumulated discussion text |
961
- | **Phase** | Current roundtable state |
962
- | **Extend** | Continue a concluded roundtable |
963
- | **noReply** | Injected message that does not trigger an AI response |
964
- | **ToolCallSummary** | Record of a tool used by an agent: name + output preview (500 chars) |
965
- | **Jaccard (bigrams)** | Text similarity algorithm used in loop detection: `|A∩B|/|A∪B|` over character pairs |
966
- | **Default observer** | Built-in plugin mechanism that consolidates the debate into an executive summary without relying on an external agent |
967
-
968
- ---
969
-
970
- ## 14. References
971
-
972
- - [OpenCode Plugin SDK](https://opencode.ai/docs/sdk/)
973
- - [OpenCode Plugin API](https://opencode.ai/docs/plugins/)
974
- - [OpenCode Agent Config](https://opencode.ai/docs/agents/)
975
- - [OpenCode SDK Types (GitHub)](https://github.com/anomalyco/opencode/tree/dev/packages/sdk/js/src)
976
- - `opencode-sessions` (reference plugin):
977
- `~/.cache/opencode/packages/opencode-sessions/node_modules/opencode-sessions/dist/index.js`
978
- - `@opencode-ai/sdk` types:
979
- `~/.cache/opencode/packages/opencode-sessions/node_modules/@opencode-ai/sdk/dist/gen/types.gen.d.ts`
1
+ # Roundtable Plugin — Technical Spec
2
+
3
+ OpenCode plugin that orchestrates **multi-agent round-robin debates** with
4
+ isolated sessions, shared context between debaters, a built-in observer
5
+ (with override), and extension support.
6
+
7
+ ---
8
+
9
+ ## Table of Contents
10
+
11
+ - [1. Philosophy](#1-philosophy)
12
+ - [2. Architecture](#2-architecture)
13
+ - [3. Tool API](#3-tool-api)
14
+ - [4. Lifecycle](#4-lifecycle)
15
+ - [5. States & Persistence](#5-states--persistence)
16
+ - [6. History & Context](#6-history--context)
17
+ - [7. Edge Case Handling](#7-edge-case-handling)
18
+ - [8. Observer](#8-observer)
19
+ - [9. Extend Mode](#9-extend-mode)
20
+ - [10. Navigation & TUI](#10-navigation--tui)
21
+ - [11. Plugin Structure](#11-plugin-structure)
22
+ - [12. Tests & Validation](#12-tests--validation)
23
+ - [13. Glossary](#13-glossary)
24
+ - [14. References](#14-references)
25
+
26
+ ---
27
+
28
+ ## 1. Philosophy
29
+
30
+ A roundtable simulates a **panel discussion** where experts from different
31
+ areas debate a topic. Each participant keeps their own personality (system
32
+ prompt, tools, temperature, color) but shares the same discussion history.
33
+
34
+ ### Principles
35
+
36
+ 1. **Clean slate on entry** — the roundtable session starts fresh, without
37
+ baggage from the original conversation.
38
+ 2. **Shared context between debaters** — everyone sees the same discussion
39
+ history, including previous tool calls and outputs.
40
+ 3. **Isolated personalities** — each agent keeps its own system prompt,
41
+ tools, and configuration. No "personality merging".
42
+ 4. **Separate orchestrator** — whoever invokes the roundtable does not
43
+ participate; they only receive the result at the end.
44
+ 5. **Default observer** — the plugin has a built-in observer that always
45
+ consolidates the debate at the end. The orchestrator may override it
46
+ with a specific agent.
47
+ 6. **Extensible** — roundtables can be continued with additional rounds.
48
+ 7. **File persistence** — state survives restarts via JSON files on disk.
49
+
50
+ ---
51
+
52
+ ## 2. Architecture
53
+
54
+ ```
55
+ ┌──────────────────────────────────────────────────────────────────┐
56
+ MAIN SESSION (S1)
57
+ ┌────────────────────────────────────────────────────────────┐
58
+ build/plan: roundtable({agents, prompt, rounds, observer?})
59
+ └────────────────────────────────────────────────────────────┘
60
+ │ │
61
+ │ │ plugin creates │
62
+
63
+ ┌────────────────────────────────────────────────────────────┐
64
+ │ │ ROUNDTABLE SESSION (S2) — parentID: S1 │ │
65
+ │ │ │ │
66
+ │ │ [noReply] Parent: #S1 │ │
67
+ │ │ [agent:PM] Round 1 — PM responds │ │
68
+ │ │ [agent:DEV] Round 1 — DEV responds (sees history) │ │
69
+ │ │ [agent:RV] Round 1 RV responds │ │
70
+ │ │ ... N rounds ... │ │
71
+ │ │ [observer] Consolidated summary │ │
72
+ │ │ [noReply] ━━━ Roundtable Concluded ━━━ (delimiter) │ │
73
+ │ │
74
+ └────────────────────────────────────────────────────────────┘
75
+ │ │
76
+ │ │ plugin resolves pending promise with summary │
77
+
78
+ │ [noReply] ⚙ Roundtable started — #S2 • agents • N round(s) │
79
+ └──────────────────────────────────────────────────────────────────┘
80
+ ```
81
+
82
+ ### Session relationship
83
+
84
+ - **S1 (Main):** where the user interacts. The session that calls `roundtable()`.
85
+ - **S2 (Roundtable):** created via `session.create({ parentID: S1 })`. Child
86
+ sessions use SubagentFooter natively and are hidden from the sidebar.
87
+ - The plugin listens to events from BOTH: `session.idle` on S2 for sequencing,
88
+ `session.deleted` on S1 or S2 for cleanup.
89
+
90
+ ### Execution model (event-driven)
91
+
92
+ The OpenCode V2 server operates **asynchronously by design**:
93
+
94
+ 1. `session.prompt()` returns immediately with an `Admitted` record
95
+ 2. The agent execution loop runs in the background
96
+ 3. When processing finishes, a `session.idle { sessionID }` event is emitted
97
+ 4. The plugin listens to these events to advance the round-robin
98
+
99
+ This makes the roundtable **native to OpenCode's model** — no polling or
100
+ blocking required.
101
+
102
+ ### Why `session.create()` and not `session.fork()`?
103
+
104
+ `fork()` copies ALL history from the current session, including tool calls,
105
+ user conversation, etc. This pollutes the roundtable context.
106
+
107
+ `create()` starts from scratch. The plugin builds the debate history manually,
108
+ including only what matters.
109
+
110
+ ### File-based persistence (not META blocks)
111
+
112
+ State is stored in JSON files at `~/.config/opencode/roundtable-states/<sessionID>.json`.
113
+ The plugin **no longer** uses `[ROUNDTABLE META]` message blocks in S2.
114
+
115
+ | Function | Purpose |
116
+ |----------|---------|
117
+ | `saveStateFile(state)` | Writes state to disk |
118
+ | `loadStateFile(sessionID)` | Reads state from disk (with validation) |
119
+ | `deleteStateFile(sessionID)` | Removes state file on cleanup |
120
+ | `listStateFiles()` | Lists all session IDs with state files |
121
+
122
+ On startup, the plugin runs `scanOrphanRoundtables()` which loads all state
123
+ files into the in-memory `states` Map.
124
+
125
+ ---
126
+
127
+ ## 3. Tool API
128
+
129
+ ### Roundtable tool
130
+
131
+ ```typescript
132
+ roundtable({
133
+ agents?: string[], // Names in speaking order (min 2). Required for new debates.
134
+ prompt: string, // Topic/challenge. For multi-round, include per-round instructions.
135
+ rounds?: number, // Complete rounds (default: 1, max: 50)
136
+ observer?: string, // Agent for final consolidation (default: built-in)
137
+ sessionID?: string, // ses_xxxxpass to extend a concluded roundtable
138
+ title?: string, // Custom title (auto-generated if omitted)
139
+ observerPrompt?: string, // Override the observer consolidation prompt entirely
140
+ })
141
+ ```
142
+
143
+ **Returns:** `{ sessionID: string, summary: string }` after the debate concludes.
144
+
145
+ **Side effect:** injects system-level prompts into each agent's context during
146
+ the debate (role-setting, topic, turn routing, and lifecycle signals).
147
+
148
+ ### Parameters
149
+
150
+ | Parameter | Type | Required | Default | Description |
151
+ |-----------|------|----------|---------|-------------|
152
+ | `agents` | `string[]` | No* | — | Agent names in speaking order (min 2, schema `minItems:2`). Required for new debates; omit when extending |
153
+ | `prompt` | `string` | Yes | — | Topic/challenge. For multi-round, include per-round instructions. All agents see the full agenda |
154
+ | `rounds` | `number` | No | `1` | Number of complete rounds (schema `minimum:1`, `maximum:50`) |
155
+ | `observer` | `string` | No | `built-in` | Agent name for final consolidation. Observer does not debate — it summarizes after all rounds |
156
+ | `sessionID` | `string` | No* | — | Session ID (format: `ses_xxxx`). Pass to extend a concluded roundtable. Omit (and pass `agents`) for a fresh debate |
157
+ | `title` | `string` | No | Auto | Custom title (max 200 chars). Auto-generated as `"(Roundtable) - {first 60 chars of prompt, truncated at word boundary}"` if omitted |
158
+ | `observerPrompt` | `string` | No | — | Overrides the default observer consolidation prompt. Use to control format — e.g., `"Output as JSON"`, `"Save a detailed report to report.md"`, `"Focus only on technical decisions"` |
159
+
160
+ ### Available agents tool
161
+
162
+ ```typescript
163
+ available_agents()
164
+ ```
165
+
166
+ **Returns** a formatted list of configured agent names:
167
+
168
+ ```
169
+ Available agents: pm, dev, rv, plan, build
170
+ ```
171
+
172
+ ### Active roundtables tool
173
+
174
+ ```typescript
175
+ active_roundtables()
176
+ ```
177
+
178
+ **Returns** a formatted list of active roundtables with clickable session IDs:
179
+
180
+ ```
181
+ Active roundtables:
182
+ - #ses_xxx · pm→dev→rv (R1/2) · debating
183
+ - #ses_yyy · pm→dev (R2/2) · consolidating
184
+ ```
185
+
186
+ Each entry shows session ID, agents in speaking order, current round, and phase
187
+ status (`debating`, `consolidating`, or `concluded`).
188
+
189
+ ### Mode inference
190
+
191
+ The tool infers the operation mode from the presence of `sessionID`:
192
+ - **No `sessionID`** → starts a fresh debate (requires `agents`)
193
+ - **`sessionID` present** continues a concluded roundtable (uses stored agent config)
194
+
195
+ Passing both `agents` and `sessionID` returns an error.
196
+
197
+ ### Return value (tool execute)
198
+
199
+ The tool blocks until the debate concludes (via a pending Promise) and returns
200
+ a string result:
201
+
202
+ ```
203
+ ━━━ Roundtable Concluded ━━━
204
+ Topic: {prompt}
205
+ Participants: {agents}
206
+
207
+ ── PM (Round 1) ──
208
+ {response}
209
+ • toolName → {outputPreview}
210
+ ...
211
+ ```
212
+
213
+ If an error occurs (invalid agents, session not found, etc.) the tool returns
214
+ an error string prefixed with `"Error:"`. The tool never hangs — error returns
215
+ are immediate.
216
+
217
+ ---
218
+
219
+ ## 4. Lifecycle
220
+
221
+ ### Full flow (fresh debate)
222
+
223
+ ```
224
+ PHASE 1 — INITIALIZATION
225
+ ─────────────────────────
226
+ 1. Orchestrator agent calls roundtable()
227
+ 2. Plugin validates agents against ctx.client.app.agents()
228
+ 3. Plugin creates S2 via session.create({ parentID: S1, title }) — title is
229
+ "(Roundtable) - {prompt[:57]}..." or custom title
230
+ 4. Plugin stores initial state in in-memory Map + saveStateFile()
231
+ 5. Plugin sends first prompt to agents[0] via session.prompt({agent})
232
+ (Prompt returns immediately LLM runs in background)
233
+ 6. Plugin injects noReply in S1: ⚙ Roundtable started — #S2 • agents • N round(s)
234
+ 7. Plugin injects noReply in S2: Parent: #S1
235
+ 8. If navigation === "auto", auto-navigate to S2
236
+ 9. Plugin shows toast "Roundtable started in #S2"
237
+ 10. Tool execute blocks with pending Promise
238
+
239
+ PHASE 2 DEBATE (repeats for each round)
240
+ ──────────────────────────────────────────
241
+ 11. agents[i] finishes session.idle fires on S2
242
+ 12. Plugin reads response with session.messages(S2)
243
+ 13. Plugin extracts text + tool call summaries
244
+ 14. Plugin appends HistoryEntry to state.history
245
+ 15. Plugin calls saveStateFile() after each turn
246
+ 16. Plugin checks detectLoop() if True, finalizes early
247
+ 17. Plugin decides next step:
248
+ a) Still have agents in this round? agents[i+1], sendToAgent()
249
+ b) Round complete + more rounds left? agents[0], round++, sendToAgent()
250
+ c) All rounds complete? → PHASE 3
251
+
252
+ Session title updates dynamically during debate:
253
+ ⚡ "prompt..." · pm→dev→rv (R1/2 · ↑ #S1)
254
+
255
+ PHASE 3 OBSERVER
256
+ ──────────────────
257
+ 18. Plugin decides which observer to use:
258
+ a) Explicit observer? sends full prompt to that agent
259
+ b) Default observer? → sends DEFAULT_OBSERVER_PROMPT to S2
260
+ 19. Observer consolidates session.idle
261
+ 20. Plugin extracts summary appends to history, saves file
262
+ 21. phase = "done"
263
+
264
+ PHASE 4 FINALIZATION
265
+ ──────────────────────
266
+ 22. Plugin builds consolidated summary from full history
267
+ 23. Plugin resolves the pending Promise with the summary
268
+ 24. Plugin injects delimiter noReply in S2:
269
+ "━━━ Roundtable Concluded ━━━
270
+ Messages below this line are not part of the original debate.
271
+ The result was consolidated in the main session."
272
+ 25. Plugin updates S2 title: "prompt..." · pm→dev→rv
273
+ 26. Plugin calls saveStateFile() with final state
274
+ 27. If navigation === "auto", auto-navigate back to S1
275
+ 28. Plugin shows toast "Roundtable concluded"
276
+ 29. Plugin clears in-memory state for S2
277
+ ```
278
+
279
+ ### Flow (extend)
280
+
281
+ ```
282
+ 1. Orchestrator calls roundtable({sessionID, rounds, prompt})
283
+ 2. Plugin validates session exists via session.get()
284
+ 3. Plugin loads serialized state via loadStateFile(sessionID)
285
+ 4. Plugin validates phase was "done" and agents match
286
+ 5. Plugin creates NEW state with:
287
+ - accumulated rounds (totalRounds += new_rounds)
288
+ - preserved history + errors
289
+ - extended prompt via buildExtendedPrompt()
290
+ - phase = "active"
291
+ 6. Plugin stores new state in Map + saveStateFile()
292
+ 7. Plugin updates S2 title with new round info
293
+ 8. Plugin injects noReply in S1: ⚙ Roundtable extended — #S2 • +N round(s)
294
+ 9. Plugin sends extended prompt to agents[0]
295
+ 10. Continues normal PHASE 2 flow
296
+ ```
297
+
298
+ ---
299
+
300
+ ## 5. States & Persistence
301
+
302
+ ### Roundtable state machine
303
+
304
+ ```
305
+ ┌────────────┐
306
+ │ ACTIVE │ (debate in progress)
307
+ └─────┬──────┘
308
+ all rounds complete
309
+
310
+ ┌────────────┐
311
+ │ OBSERVING │ (observer — built-in or explicit — always runs)
312
+ └─────┬──────┘
313
+ observer summarizes
314
+
315
+ ┌────────────┐
316
+ │ DONE │
317
+ └────────────┘
318
+
319
+ At any state:
320
+ session.deleted / fatal error
321
+
322
+ ┌────────────┐
323
+ │ ABORTED │
324
+ └────────────┘
325
+ ```
326
+
327
+ > **Note**: `OBSERVING` **always** runs. The built-in default observer
328
+ > guarantees every debate produces a consolidated summary at the end.
329
+
330
+ ### In-memory state type
331
+
332
+ ```typescript
333
+ interface RoundtableState {
334
+ // Identification
335
+ sessionID: string // S2
336
+ parentSessionID: string // S1
337
+
338
+ // Config
339
+ agents: string[]
340
+ totalRounds: number
341
+ observer: "built-in" | string // "built-in" = plugin default observer
342
+ prompt: string
343
+
344
+ // Progress
345
+ currentRound: number // 0-indexed
346
+ currentAgentIndex: number // 0-indexed
347
+ phase: "active" | "observing" | "done" | "aborted"
348
+ currentGeneration: number // incremented each turn, used for timeout staleness
349
+
350
+ // Data
351
+ history: HistoryEntry[]
352
+ errors: string[]
353
+ createdAt: number
354
+ lastProcessedMsgId?: string // prevents duplicate processing
355
+ userInterjections: string[] // user messages typed in S2 during debate
356
+ }
357
+
358
+ interface HistoryEntry {
359
+ agent: string
360
+ round: number
361
+ response: string // plain text of the response
362
+ toolCalls: ToolCallSummary[] // tool names + output previews
363
+ hasError: boolean
364
+ }
365
+
366
+ interface ToolCallSummary {
367
+ toolName: string
368
+ outputPreview: string // first N chars of output (configurable, default 500)
369
+ }
370
+ ```
371
+
372
+ ### File-based persistence
373
+
374
+ State is persisted as JSON files at:
375
+
376
+ ```
377
+ ~/.config/opencode/roundtable-states/<sessionID>.json
378
+ ```
379
+
380
+ The directory is determined by `$XDG_CONFIG_HOME` (if set) or
381
+ `~/.config/opencode/roundtable-states/`.
382
+
383
+ Key behaviors:
384
+ - `saveStateFile()` is called after state mutations (turn complete, phase
385
+ change, error, etc.)
386
+ - `loadStateFile()` validates required fields before returning
387
+ - `deleteStateFile()` is called when a session is deleted
388
+ - `listStateFiles()` lists all `.json` files in the directory
389
+ - On plugin startup, `scanOrphanRoundtables()` loads all state files into
390
+ the in-memory `states` Map for recovery and active listing
391
+
392
+ ### In-memory maps
393
+
394
+ ```typescript
395
+ const states = new Map<string, RoundtableState>()
396
+ const timeoutHandles = new Map<string, ReturnType<typeof setTimeout>>()
397
+ const pendingResults = new Map<string, { resolve: (output: string) => void }>()
398
+ ```
399
+
400
+ - `states` all known roundtables, synced from files on startup
401
+ - `timeoutHandles` per-agent timer handles for timeout enforcement
402
+ - `pendingResults` — Promise resolvers that unblock the `roundtable()` tool
403
+
404
+ ---
405
+
406
+ ## 6. History & Context
407
+
408
+ ### Content sent to each agent
409
+
410
+ Each time the plugin passes the turn (via `buildAgentPrompt`), it sends a
411
+ minimal message with:
412
+
413
+ ```
414
+ [System] You are participating in a roundtable debate. Agents (A → B → C) will discuss the topic below in N round(s).
415
+ [Topic] {prompt}
416
+
417
+ Round N/M | Current agent: Z
418
+
419
+ [System] This is the last prompt finalize your thoughts (last round only)
420
+ ```
421
+
422
+ Key points:
423
+ - No `[ROUNDTABLE META]` block is injected (state is file-based)
424
+ - Agents are NOT instructed to ignore META blocks
425
+ - The initial system message is only included on the first turn
426
+ - The "finalize your thoughts" hint is added on the last turn
427
+
428
+ ### What the plugin includes in history
429
+
430
+ The accumulated history contains **everything relevant for consolidation**:
431
+
432
+ - `TextPart.text` — agent's textual response
433
+ - `ToolCallSummary` — tool name + **output preview** (configurable, default 500 characters).
434
+ If DEV ran `ls src/`, the next agent sees the discovered directory structure.
435
+ - Errors and failures are tracked per entry
436
+
437
+ ### What the plugin does NOT include in history
438
+
439
+ - `ReasoningPart.text` — agent's internal reasoning (stays in S2 session)
440
+ - `StepStartPart` / `StepFinishPart` — execution metadata
441
+
442
+ ### History visibility to agents
443
+
444
+ History is accumulated server-side in the plugin state. It is NOT currently
445
+ injected into subsequent agent prompts each agent only sees its current
446
+ turn's context. The full history is used by the observer for consolidation
447
+ and appears in the final summary returned to S1.
448
+
449
+ ### User interjections
450
+
451
+ When a user types a message in S2 during the debate, the plugin tracks those
452
+ messages in `state.userInterjections[]`. These messages do not break the
453
+ state machine the round-robin continues after the user's message.
454
+
455
+ ---
456
+
457
+ ## 7. Edge Case Handling
458
+
459
+ ### 7.1 Agent failure (provider error)
460
+
461
+ ```
462
+ session.error on S2 with agent = currentAgent
463
+ → Logs error in state.errors[]
464
+ → Advances to next agent (or next round, or aborts)
465
+ Toast: "agent failed on Round N. Skipping to next."
466
+ If all agents fail → aborts with error
467
+ ```
468
+
469
+ ### 7.2 User closes S2 (session.deleted on S2)
470
+
471
+ ```
472
+ session.deleted on S2 (deletedSessionID === state.sessionID)
473
+ Deletes state file
474
+ → phase = "aborted"
475
+ Builds partial consolidated summary
476
+ Resolves pending Promise with:
477
+ "[Roundtable interrupted session closed.
478
+ Partial history up to interruption:]
479
+ {partial summary}"
480
+ Toast: "Roundtable interrupted"
481
+ Clears in-memory state
482
+ ```
483
+
484
+ ### 7.3 User closes S1 (session.deleted on S1)
485
+
486
+ ```
487
+ session.deleted on S1 (deletedSessionID === state.parentSessionID)
488
+ Deletes state file
489
+ → phase = "aborted"
490
+ → Aborts S2 via session.abort({path:{id:S2}})
491
+ Clears in-memory state
492
+ ```
493
+
494
+ ### 7.4 User types a message in S2 during the debate
495
+
496
+ S2 is a visible and interactive session. If the user switches to S2
497
+ and types something:
498
+
499
+ - The message enters S2's context
500
+ - If the current agent is still processing, the user's message will be
501
+ processed after the agent finishes
502
+ - The plugin continues the round-robin — the user's message becomes part
503
+ of the context
504
+ - The plugin tracks interjections in `state.userInterjections[]`
505
+
506
+ **Behavior**: natural and desired. The user can intervene in the debate.
507
+
508
+ ### 7.5 Debate loop (agents repeating arguments)
509
+
510
+ After each turn, the plugin checks similarity between the latest response
511
+ and the previous one using **Jaccard similarity of bigrams (character pairs)**:
512
+
513
+ ```
514
+ bigrams = set of all consecutive character pairs in cleaned text
515
+ similarity = |bigrams(current) ∩ bigrams(previous)| / |bigrams(current) ∪ bigrams(previous)|
516
+
517
+ if similarity > threshold (default 0.85):
518
+ "Loop detected agents reached an impasse"
519
+ phase = "done", finalizes early with partial result
520
+ ```
521
+
522
+ **Why Jaccard bigrams?**
523
+ - Purely computational (no LLM calls or embeddings needed)
524
+ - Zero external dependencies
525
+ - Reasonable for detecting textual argument repetition
526
+ - Threshold is configurable via `loopSimilarityThreshold` in config
527
+
528
+ ### 7.6 Agent timeout
529
+
530
+ - Each agent turn has a configurable timeout (default **5 minutes**)
531
+ - Implemented via `setTimeout` + generation counter (stale check)
532
+ - If it expires: `session.abort({path:{id:S2}})` skip to next agent
533
+ - Timeout handle is tracked in `timeoutHandles` map
534
+
535
+ ### 7.7 Multiple simultaneous roundtables
536
+
537
+ Each roundtable has its own state in `Map<sessionID, RoundtableState>`.
538
+ They are independent. The plugin allows multiple roundtables running in
539
+ parallel.
540
+
541
+ ### 7.8 State recovery after restart
542
+
543
+ If OpenCode restarts mid-roundtable:
544
+ - In-memory state is lost
545
+ - State JSON files persist on disk at `~/.config/opencode/roundtable-states/`
546
+ - S2 session remains in the database
547
+
548
+ **Recovery**: on initialization, the plugin runs `scanOrphanRoundtables()`:
549
+ ```
550
+ 1. List all .json files in roundtable-states directory
551
+ 2. For each file, loadStateFile() and add to in-memory states Map
552
+ 3. Log: "scanOrphanRoundtables: N state(s) loaded, M error(s)"
553
+ ```
554
+
555
+ Recovered states with `phase: "active"` become visible via `active_roundtables()`
556
+ but the debate does not auto-resume the user must decide whether to extend
557
+ or close the session.
558
+
559
+ ### 7.9 Compaction during a roundtable
560
+
561
+ The plugin registers an `experimental.session.compacting` hook (currently a
562
+ no-op). Future phase 4 will re-inject critical state during compaction to
563
+ prevent data loss.
564
+
565
+ ### 7.10 Human message in S2 after conclusion
566
+
567
+ After `phase = "done"` (roundtable concluded):
568
+ - The plugin has already injected the `━━━ Roundtable Concluded ━━━`
569
+ delimiter in S2
570
+ - The plugin's `processNextTurn` checks `state.phase === "done" | "aborted"`
571
+ and returns early — it no longer processes events for this session
572
+ - If the user types in S2, OpenCode responds normally with the session's
573
+ default agent
574
+ - The response **does not** alter the result already consolidated in S1
575
+
576
+ ```
577
+ [noReply] ━━━ Roundtable Concluded ━━━
578
+ Messages below this line are not part of the original debate.
579
+ The result was consolidated in the main session.
580
+ ── You ──
581
+ (conversation can continue here, but the plugin no longer
582
+ manages this session.)
583
+ ```
584
+
585
+ **Behavior**: natural and transparent. S2 becomes a regular session after
586
+ the roundtable concludes.
587
+
588
+ ### 7.11 Error during observer phase
589
+
590
+ If the observer fails (provider error in `OBSERVING` phase):
591
+ - `handleAgentError` sets `phase = "aborted"`
592
+ - Calls `finalizeRoundtable()` which builds partial summary from existing
593
+ history and resolves the pending Promise
594
+
595
+ ---
596
+
597
+ ## 8. Observer
598
+
599
+ ### Function
600
+
601
+ The observer **always consolidates** the debate at the end. It does not
602
+ participate in rounds it enters after all rounds are complete to produce
603
+ an executive summary.
604
+
605
+ ### Default observer (built-in)
606
+
607
+ If the orchestrator does not specify `observer`, the plugin uses a built-in
608
+ observer with the following prompt:
609
+
610
+ ```
611
+ DEFAULT_OBSERVER_PROMPT = `
612
+ You are an impartial roundtable observer.
613
+ Consolidate the debate below into:
614
+
615
+ 1. **Executive summary** (2-3 sentences)
616
+ 2. **Key points** raised by each participant
617
+ 3. **Decisions or convergences** reached
618
+ 4. **Remaining open questions**
619
+ 5. **Suggested next steps**
620
+
621
+ Debate:
622
+ {full_history}
623
+ `
624
+ ```
625
+
626
+ This prompt is sent to the S2 session itself (no specific agent), using the
627
+ session's default model/provider.
628
+
629
+ ### Explicit observer (override)
630
+
631
+ If the orchestrator passes `observer: "rv"`, the plugin:
632
+ 1. Builds a similar prompt, but with `Your role: rv. Provide an executive summary...`
633
+ 2. Sends via `session.prompt({ agent: "rv" })` on the S2 session
634
+ 3. The `rv` agent responds with its own tools and personality
635
+
636
+ ### Flow
637
+
638
+ ```
639
+ 1. All rounds complete → transition to OBSERVING
640
+ 2. Plugin decides:
641
+ a) Explicit observer? → sends to that agent
642
+ b) Default observer? sends DEFAULT_OBSERVER_PROMPT to S2
643
+ 3. Observer responds → session.idle
644
+ 4. Plugin extracts text, appends to history, saves state file
645
+ 5. phase = "done", finalizeRoundtable() called
646
+ ```
647
+
648
+ ### Observer prompt is configurable
649
+
650
+ **Per-call override (runtime):** pass `observerPrompt` in the `roundtable()` call.
651
+ This completely replaces the observer prompt for that debate:
652
+
653
+ ```
654
+ roundtable({
655
+ agents: ["pm", "dev", "rv"],
656
+ prompt: "...",
657
+ observerPrompt: "Output only a single emoji that captures the discussion",
658
+ })
659
+ ```
660
+
661
+ The `observerPrompt` is persisted in the state file and carried over on extend
662
+ (unless a new `observerPrompt` is provided in the extend call).
663
+
664
+ **Static override (config):** the `defaultObserverPrompt` field in
665
+ `roundtable.json` replaces the built-in template for all debates that don't
666
+ provide a per-call `observerPrompt`.
667
+
668
+ ---
669
+
670
+ ## 9. Extend Mode
671
+
672
+ ### Usage
673
+
674
+ ```typescript
675
+ roundtable({
676
+ sessionID: "ses_abc123", // Original S2 ID
677
+ rounds: 2,
678
+ prompt: "Dive deeper into operational costs",
679
+ })
680
+ ```
681
+
682
+ ### Prompt semantics in extend mode
683
+
684
+ The `prompt` parameter in `extend` mode can be:
685
+
686
+ | Intent | Example | Effect |
687
+ |--------|---------|--------|
688
+ | **Continuation** | "Debate more about X" | New prompt is sent as a complement to the original topic. `final_prompt = "Original topic: {original.prompt}\n\nContinuation: {prompt}"` |
689
+ | **New topic** | "Now plan Y" | New prompt replaces the topic, but previous history is preserved as context. `final_prompt = "Previous discussion history preserved. Original topic was: ...\n\nNew challenge: {prompt}"` |
690
+
691
+ The plugin infers intent heuristically: if the prompt starts with
692
+ "Debate more", "Continue", "Dive deeper", "Expand on", "Elaborate",
693
+ "Further discuss", or "Keep debating" → continuation. Otherwise → new topic.
694
+
695
+ ### Flow
696
+
697
+ ```
698
+ 1. Plugin validates session exists via session.get(sessionID)
699
+ 2. Plugin loads state from file via loadStateFile(sessionID)
700
+ 3. Plugin validates:
701
+ a) phase === "done" (cannot extend active/observing)
702
+ b) agents match (if passed)
703
+ c) all original agents still exist on server
704
+ 4. Creates NEW state with:
705
+ - same sessionID, parentSessionID, agents, observer
706
+ - totalRounds += new_rounds (accumulative)
707
+ - history + errors from original
708
+ - extended_prompt from buildExtendedPrompt()
709
+ - phase = "active"
710
+ 5. Stores new state in Map + saveStateFile()
711
+ 6. Updates S2 title with new round info
712
+ 7. Injects noReply in S1: Roundtable extended #S2 • +N round(s)
713
+ 8. Sends extended_prompt to agents[0]
714
+ 9. Continues normal PHASE 2 flow
715
+ ```
716
+
717
+ ### Constraints
718
+
719
+ - Only works if S2 still exists (was not deleted)
720
+ - Only works if original phase was "done"
721
+ - Agents and observer must be the same as the original roundtable (agents
722
+ are optional in the request, but if provided they must match exactly)
723
+ - Previous discussion history is always preserved
724
+ - If the original roundtable used an explicit observer, the extend also uses
725
+ it (observer type is stored in state file)
726
+
727
+ ### Stuck session recovery
728
+
729
+ If an extend is interrupted mid-way (TUI freeze, process kill, etc.), the
730
+ state file has `phase: "active"` but no agent is running. The next extend
731
+ call returns an error indicating the session is in an invalid state.
732
+ The user must manually close the S2 and start fresh.
733
+
734
+ ---
735
+
736
+ ## 10. Navigation & TUI
737
+
738
+ ### Navigation modes
739
+
740
+ Configurable via `navigation` in `roundtable.json`:
741
+
742
+ | Mode | Value | Behavior |
743
+ |------|-------|----------|
744
+ | **Link** | `"link"` (default) | No auto-navigation. Relies on native `#ses_xxx` link rendering in the TUI for user to navigate manually |
745
+ | **Auto** | `"auto"` | Auto-navigates S1 → S2 on create, and S2 → S1 on conclude. Uses `navigateToSession()` helper which calls `tui.publish({ type: "tui.session.select" })` |
746
+ | **Disabled** | `"none"` | No automatic navigation. No link rendering relied upon |
747
+
748
+ `navigateToSession()` tries `ctx.client.tui.selectSession()` first, falling
749
+ back to `tui.publish({ type: "tui.session.select" })`.
750
+
751
+ ### Session titles
752
+
753
+ | State | Format |
754
+ |-------|--------|
755
+ | Active (auto-generated) | `(Roundtable) - {prompt[:57]}...` |
756
+ | During debate | `⚡ "{prompt[:37]}..." · agent1→agent2 (R1/2 · ↑ #parentID)` |
757
+ | Concluded | `⚡ "{prompt[:37]}..." · agent1→agent2 ✓` |
758
+
759
+ ### Compact S1 markers
760
+
761
+ Instead of verbose serialization, S1 gets minimal noReply markers:
762
+
763
+ | Event | Marker |
764
+ |-------|--------|
765
+ | Roundtable started | `⚙ Roundtable started — #ses_xxx • agents • N round(s)` |
766
+ | Roundtable extended | `⚙ Roundtable extended — #ses_xxx • +N round(s)` |
767
+
768
+ ### S2 markers
769
+
770
+ | Purpose | Marker |
771
+ |---------|--------|
772
+ | Parent reference | `⚙ Parent: #ses_yyy` compact marker for TUI navigation |
773
+
774
+ ### TUI Plugin
775
+
776
+ Registered in `tui.json`, exported at `./tui`, the TUI plugin provides:
777
+
778
+ | Feature | Implementation |
779
+ |---------|---------------|
780
+ | **Badge `[RT]`** | `api.slots.register({ sidebar_title })` — shows `[RT]` badge on sessions whose title starts with `⚡` |
781
+ | **← Back link** | `api.slots.register({ sidebar_content })` — shows a clickable `← Back` link on child sessions that navigates to the parent |
782
+ | **`/roundtables` command** | `api.command.register()` — slash command opens a dialog listing all active roundtable sessions. Clicking a session navigates to it |
783
+ | **Toast notifications** | `api.client.tui.showToast()` — on start, completion, errors, and interruptions |
784
+
785
+ ### TUI appearance example
786
+
787
+ ```
788
+ ┌─────────────────────────────────────────────────┐
789
+ │ Main Session (S1) [sidebar: no RT badge] │
790
+ ├─────────────────────────────────────────────────┤
791
+ │ You: roundtable({agents:[pm,dev], rounds:2}) │
792
+ │ │
793
+ │ Build: Roundtable started #abc123 │
794
+ │ • pm → dev • 2 round(s) │
795
+ │ │
796
+ │ [tool returns consolidated summary │
797
+ │ ━━━ Roundtable Concluded ━━━ │
798
+ │ Topic: What architecture? │
799
+ │ PM: microservices... │
800
+ │ DEV: monolith... │
801
+ │ Conclusion: modular monolith] │
802
+ └─────────────────────────────────────────────────┘
803
+
804
+ ┌─────────────────────────────────────────────────┐
805
+ │ Roundtable Session #abc123 [sidebar: [RT]] │
806
+ │ ← Back (clickable link) │
807
+ ├─────────────────────────────────────────────────┤
808
+ │ [noReply] Parent: #S1 │
809
+ ── PM ── │
810
+ │ The microservices approach allows... │
811
+ ── DEV ── │
812
+ Disagree, the complexity is not justified... │
813
+ │ ── PM (R2) ── │
814
+ OK, but what if we split only the... │
815
+ │ ── DEV (R2) ── │
816
+ │ That's basically a modular monolith... │
817
+ │ [noReply] ━━━ Roundtable Concluded ━━━ │
818
+ └─────────────────────────────────────────────────┘
819
+ ```
820
+
821
+ ### Recommended color config
822
+
823
+ ```json
824
+ {
825
+ "agent": {
826
+ "pm": { "color": "#3498db" },
827
+ "dev": { "color": "#2ecc71" },
828
+ "rv": { "color": "#e74c3c" },
829
+ "plan": { "color": "#f39c12" },
830
+ "build": { "color": "#9b59b6" }
831
+ }
832
+ }
833
+ ```
834
+
835
+ ---
836
+
837
+ ## 11. Plugin Structure
838
+
839
+ ### Files
840
+
841
+ ```
842
+ opencode-roundtable/
843
+ ├── package.json # name, version, main: dist/index.js, export ./tui
844
+ ├── tsconfig.json
845
+ ├── index.ts # Plugin entry point + tool definitions + event handler
846
+ ├── src/
847
+ │ ├── types.ts # Interfaces: RoundtableState, HistoryEntry, ToolCallSummary, etc.
848
+ │ ├── config.ts # loadConfig, validateConfig, defaults (roundtable.json)
849
+ │ ├── state.ts # In-memory maps + file persistence (saveStateFile, loadStateFile, etc.)
850
+ │ ├── prompts.ts # buildAgentPrompt, buildObserverPrompt
851
+ │ ├── handlers.ts # All orchestration logic (start, extend, process turns, finalize, errors)
852
+ │ ├── utils.ts # detectLoop, extractResponse, buildToolSummaries, navigateToSession, etc.
853
+ │ └── tui/
854
+ │ └── tui.tsx # TUI plugin (badge, sidebar link, /roundtables command)
855
+ ├── docs/
856
+ │ ├── SPEC.md
857
+ │ ├── IMPLEMENTATION.md
858
+ │ └── roundtable.schema.json
859
+ └── README.md
860
+ ```
861
+
862
+ ### Dependencies
863
+
864
+ None external. Only `@opencode-ai/plugin` (peer dependency of OpenCode).
865
+
866
+ ### Hooks used
867
+
868
+ | Hook | Purpose |
869
+ |------|---------|
870
+ | `event` | Listens to `session.idle`, `session.error`, `session.deleted` |
871
+ | `experimental.session.compacting` | Placeholder for state preservation during compaction |
872
+ | `tool` | Defines the `roundtable`, `available_agents`, and `active_roundtables` tools |
873
+
874
+ ### SDK APIs used
875
+
876
+ | API | Usage |
877
+ |-----|-------|
878
+ | `ctx.client.session.create()` | Create S2 |
879
+ | `ctx.client.session.prompt()` | Send messages to agents |
880
+ | `ctx.client.session.messages()` | Read agent responses |
881
+ | `ctx.client.session.abort()` | Abort timed-out agent |
882
+ | `ctx.client.session.update()` | Update session title |
883
+ | `ctx.client.session.get()` | Validate session exists (extend mode) |
884
+ | `ctx.client.tui.showToast()` | Notify user |
885
+ | `ctx.client.tui.publish()` | Auto-navigate (fallback) |
886
+ | `ctx.client.app.agents()` | Discover available agents |
887
+ | `ctx.client.app.log()` | Debug logging |
888
+
889
+ ---
890
+
891
+ ## 12. Tests & Validation
892
+
893
+ ### Test scenarios
894
+
895
+ 1. **Basic roundtable**: 2 agents, 1 round, default observer
896
+ 2. **Explicit observer**: 3 agents, 2 rounds, observer="rv" consolidates
897
+ 3. **Default observer**: same scenario, no observer param → uses built-in
898
+ 4. **Agent failure**: provider error → skip → continue
899
+ 5. **Timeout**: agent takes too long → abort + skip
900
+ 6. **Loop detection**: agents repeat arguments → Jaccard > 0.85 end debate early
901
+ 7. **Session deleted**: close S2 → partial result in S1
902
+ 8. **Parent deleted**: close S1 → S2 aborted
903
+ 9. **Extend (continuation)**: conclude → extend with "Debate more about X"
904
+ 10. **Extend (new topic)**: conclude → extend with "Now plan Y"
905
+ 11. **Multiple**: 2 roundtables in parallel
906
+ 12. **Compaction**: compact S2 during debate (no-op currently)
907
+ 13. **User interjection**: user writes in S2 during debate → continues
908
+ 14. **Post-conclusion**: user writes in S2 after done → S2 becomes normal session
909
+ 15. **Startup recovery**: restart during ACTIVE → scanOrphanRoundtables detects states
910
+ 16. **Invalid agent**: agents with nonexistent name → error with available list
911
+ 17. **Available agents tool**: orchestrator calls `available_agents()` gets list
912
+ 18. **Active roundtables tool**: lists all active roundtables with status
913
+ 19. **Navigation config**: auto-navigate on create/conclude with `navigation: "auto"`
914
+
915
+ ### Acceptance criteria
916
+
917
+ - [ ] `roundtable` tool appears in the agent's tool list
918
+ - [ ] `available_agents` tool appears in the agent's tool list
919
+ - [ ] `active_roundtables` tool lists active roundtables
920
+ - [ ] S2 is created as a child of S1
921
+ - [ ] Agents speak in the specified order
922
+ - [ ] Each agent keeps its own personality
923
+ - [ ] History + tool outputs tracked in state per turn
924
+ - [ ] Default observer (built-in) consolidates the debate at the end
925
+ - [ ] Explicit observer (named agent) also works
926
+ - [ ] Final result is returned to the calling tool
927
+ - [ ] Toast notifications appear on start, error, and conclusion
928
+ - [ ] Agent errors are skipped with notification
929
+ - [ ] S2 session title updates dynamically during debate
930
+ - [ ] Session title shows `✓` on conclusion
931
+ - [ ] Extend mode resumes from state file (continuation and new topic)
932
+ - [ ] Session deletion is handled gracefully (partial result or abort)
933
+ - [ ] Loop detection works (Jaccard bigrams, configurable threshold)
934
+ - [ ] Invalid agent names return an error with the available list
935
+ - [ ] `━━━ Roundtable Concluded ━━━` delimiter appears in S2 at the end
936
+ - [ ] Post-conclusion messages in S2 do not affect the S1 result
937
+ - [ ] State files persist in `~/.config/opencode/roundtable-states/`
938
+ - [ ] Startup recovery loads state files into memory
939
+ - [ ] Navigation config respects `link`/`auto`/`none` modes
940
+ - [ ] TUI plugin shows `[RT]` badge on roundtable sessions
941
+ - [ ] TUI plugin shows `← Back` link on child sessions
942
+ - [ ] `/roundtables` slash command opens session list dialog
943
+
944
+ ---
945
+
946
+ ## 13. Glossary
947
+
948
+ | Term | Definition |
949
+ |------|------------|
950
+ | **S1** | Main session, where the user interacts |
951
+ | **S2** | Child session, where the roundtable runs |
952
+ | **Round** | One round = all agents speak once |
953
+ | **Turn** | A specific agent's turn to speak |
954
+ | **Observer** | Agent that does not debate, only summarizes |
955
+ | **Orchestrator** | Agent that called `roundtable()` |
956
+ | **History** | Accumulated discussion entries (text + tool summaries) |
957
+ | **Phase** | Current roundtable state (`active`, `observing`, `done`, `aborted`) |
958
+ | **Extend** | Continue a concluded roundtable with more rounds |
959
+ | **noReply** | Injected message that does not trigger an AI response |
960
+ | **ToolCallSummary** | Record of a tool used by an agent: name + output preview (500 chars) |
961
+ | **Jaccard (bigrams)** | Text similarity algorithm used in loop detection: `|A∩B|/|A∪B|` over character pairs |
962
+ | **Default observer** | Built-in plugin mechanism that consolidates the debate into an executive summary without relying on an external agent |
963
+ | **State file** | JSON file at `~/.config/opencode/roundtable-states/<sessionID>.json` containing full `RoundtableState` |
964
+ | **scanOrphanRoundtables** | Startup process that loads all state files into the in-memory Map |
965
+
966
+ ---
967
+
968
+ ## 14. References
969
+
970
+ - [OpenCode Plugin SDK](https://opencode.ai/docs/sdk/)
971
+ - [OpenCode Plugin API](https://opencode.ai/docs/plugins/)
972
+ - [OpenCode Agent Config](https://opencode.ai/docs/agents/)
973
+ - [OpenCode SDK Types (GitHub)](https://github.com/anomalyco/opencode/tree/dev/packages/sdk/js/src)
974
+ - `@opencode-ai/sdk` types