@rrr2010/opencode-roundtable 0.2.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.
package/docs/SPEC.md ADDED
@@ -0,0 +1,979 @@
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 consolidates → session.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`