Asks a council of models about a hard problem, when the model calls its tool or you run /council <question>: Claude models, and Gemini models when gemini-core…

Some problems do not yield to one more attempt: the same error survives every fix, or two designs each look right. A second opinion helps, and several independent ones help more. This mod asks a council of models about the problem: each member answers the same question on its own, and the session's own model, as the chair, turns their answers into one verdict. The model calls it through a tool when it is stuck, and you can run it yourself with /council <question>.
gemini-3.8-flash when gemini-core is installed and has a key. Without gemini-core, or without a key, the Gemini members are skipped and the result says why. /council members changes the list.$.model.fork, so it reads the whole conversation from the prompt cache.$.model.complete, at high effort. Above 400,000 characters the longest tool outputs are cut first, then the oldest messages are left out. Haiku 4.5 reads at most 560,000 characters, because its window is 200k tokens.Member A, Member B), not model names, and writes where the members agree, where they disagree and which side the conversation supports, what they missed, and the next step.$.session.messages() read the main thread. Every member and the chair then get the question alone, as a completion.The tool is mcp__council__convene with one question input. It is listed without ToolSearch, and a note in the system prompt tells the model when to call it:
The note is fixed at the session's start and at /clear, so /council on gives the model the tool at once and the note from the next session. There is no limit on how often the model calls it. The setting is shared by every window: a /council on in another window gives this window's model the tool at its next turn and the note at its next /clear or session, and a /council off there makes the tool refuse here at once.
With the sidebar open, a standing section follows the run: the question and how long the run has taken, then every member with its state word (running yellow, answered green with its time and tokens, failed red with the reason), then the chair, and finally the first words of the verdict. Each model's name is coloured by its family: opus red, fable yellow, sonnet green, haiku faint, Gemini blue.
council: run avg.js boş dizi için ne dönmeli: NaN, 0, yoksa hata mı fırl… · done in 44s opus 5.5 · fork · answered 11s · 89k in, 902 out sonnet 5.5 · complete · answered 17s · 6.7k in, 1.1k out fable 5.1 · complete · answered 24s · 6.7k in, 1.4k out haiku 4.5 · complete · answered 9s · 5.2k in, 634 out gemini-3.8-flash · gemini · answered 8s · 4.7k in, 1.3k out · free tier chair · opus 5.5 · fork · done 20s
With the sidebar closed, you get one line at the end:
council: 1 of 2 members answered in 4s; the chair wrote the verdict
/council on or off, the members, the chair, the last run (also /council status) /council on | off whether the model has the tool; off by default /council members the members /council members <model> ... opus, sonnet, fable, haiku, a claude- id, a gemini- or gemma- id /council members reset the default members /council <question> runs the council now, also while it is off
/council <question> answers convened: 5 members at once. When the members and the chair have answered, the mod hands the verdict to the model through /council:send, so the model reads it as your message and tells you what it takes from it. A turn that is running meanwhile holds it until the turn ends (measured on 2.1.283).
Each run makes one request per member and one for the chair. Measured on Claude Code 2.1.283:
At list prices, one run at the full 400,000 characters costs about $2.20, estimated: Fable 5.1 about $1.70 ($10 per million input tokens), Sonnet 5.5 about $0.34 (the same list price as Sonnet 5, whose token count the check measured), Haiku 4.5 about $0.13, and the two forks a few cents of cache reads. A shorter conversation costs proportionally less. On a Claude subscription the requests count toward your usage limits instead. To lower the cost, leave a member out with /council members, for example /council members opus sonnet haiku gemini-3.8-flash.
gemini-3.1-pro-preview answered HTTP 429 on every free-tier key in the check, because the free tier has no quota for it. Add it to the members only with a paid key.
claude plugin marketplace add KilimcininKorOglu/claude-code-mods claude plugin install council@kilimcininkoroglu-mods
Function hooks are early access. Claude Code 2.1.288 and later load them by default, so there is nothing to switch on.
/council on if the model should call the council by itself. It is off after an install, and /council <question> works either way.| Option | Default | What it sets |
|---|---|---|
maxInputChars | 400000 | How much of the conversation, in characters, each member reads when it does not fork the session; from 20,000 to 2,000,000 |
Validated with claude plugin validate on Claude Code 2.1.283:
❯ ./register.ts hooks: session.start, turn.start, classic.SessionStart, command.run{command=council}, prompt.section{name=env_info_simple}, tool.describe{tool=/"^mcp__council__convene$"/}, tool.call{tool=/"^mcp__council__convene$"/}, turn.step ❯ ./register.ts calls: $.clock.after (via runManual), $.clock.now (via askClaude, askGemini, askGeminiModel, convene, drawRun, ended, verdictOf), $.command.register, $.command.run (via send), $.gemini.enroll (via enrollGemini), $.gemini.read (via askGeminiModel), $.gemini.request (via askGeminiModel), $.gemini.settings (via geminiReach), $.http.fetch (via askGeminiModel), $.model.complete (via askChair, askClaude), $.model.fork (via askChair, askClaude), $.prompt.submit (via send), $.session.messages (via contextOf), $.sidebar.set (via drawRun), $.store.delete (via runCommand), $.store.get (via isEnabled, membersNow), $.store.set (via runCommand, storeEnabled), $.tool.register (via declareTool), $.ui.log (via enrollGemini, runManual, send, toPerson)
Reach L3: one request per member and one for the chair.
maxInputChars, so it can miss what was cut.$.clock.sleep counts against the hook's 10-second budget (measured on 2.1.283). Three quick retries can all hit the same load.make install # eslint, typescript-eslint, typescript make lint # complexity limit 10, the build fails above it make typecheck # needs .claude/types/ from /plugin-types with --plugin-dir ../sidebar --plugin-dir ../gemini-core make validate make test # claude plugin test
hooks/register.ts 409 lines1import type { EngineInterface, ModelForkResult, Register, SessionMessage, ToolCallResult } from 'claude-code'
2import { runLines, statusText, summaryText, type Chair, type Run, type Seat, type Via } from './board.ts'
3import { changeText, convenedText, ENABLED_KEY, MEMBERS_KEY, membersText, OFF_DENY, parseCommand } from './command.ts'
4import { configFrom, HAIKU_MAX_CHARS, MAX_TOKENS, MEMBER_MS, type Config } from './config.ts'
5import { bare, DEFAULT_MEMBERS, isSessionModel, labelOfModel, storedMembers, type Member } from './members.ts'
6import { chairPrompt, forkMemberPrompt, INPUT_SCHEMA, letterOf, MEMBER_SYSTEM, memberPrompt, noAnswerText, questionOf, resultText, sendText, SYSTEM_GUIDANCE, TOOL_DESCRIPTION, TOOL_NAME, type Lettered, type Row } from './prompts.ts'
7import { renderTranscript } from './transcript.ts'
8
9/** The name gemini-core knows this mod by, and the model it enrolls with; every request names its own. */
10const CONSUMER = 'council'
11const GEMINI_ENROLL_MODEL = 'gemini-3.8-flash'
12
13const SEND_COMMAND = 'council:send'
14
15const SECTION = { consumer: 'council', key: 'run' }
16
17/**
18 * Whether the system prompt carries the note (fixed at a session start or /clear), whether this session
19 * declared the tool, the main loop's model, and the runs.
20 */
21type State = { guidance: boolean; declared: boolean; model?: string; runs: number; current?: number; last?: string }
22
23/** Whether gemini-core can take a request now, or why not. */
24type Reach = { ok: true } | { ok: false; why: string }
25
26/** What a run knows: the question, the conversation (absent for a subagent's call), the model of the session. */
27type Ctx = { question: string; config: Config; messages?: SessionMessage[]; sessionModel?: string; gemini: Reach; rendered: Map<number, string> }
28
29type Answer = { text: string; ms: number; inTokens: number; outTokens: number; freeTier?: boolean }
30
31type Asked = { answer: Answer } | { why: string }
32
33type Outcome = { text: string } | { deny: string }
34
35function errorText(err: unknown): string {
36 return err instanceof Error ? err.message : String(err)
37}
38
39async function isEnabled($: EngineInterface): Promise<boolean> {
40 return (await $.store.get(ENABLED_KEY)) === true
41}
42
43async function membersNow($: EngineInterface): Promise<Member[]> {
44 return storedMembers(await $.store.get(MEMBERS_KEY))
45}
46
47async function declareTool($: EngineInterface, state: State): Promise<void> {
48 await $.tool.register({ name: TOOL_NAME, description: TOOL_DESCRIPTION, inputSchema: INPUT_SCHEMA })
49 state.declared = true
50}
51
52async function storeEnabled($: EngineInterface, state: State, enabled: boolean): Promise<string> {
53 await $.store.set(ENABLED_KEY, enabled)
54 if (enabled) await declareTool($, state)
55 return changeText(enabled)
56}
57
58/**
59 * gemini-core is optional: a session without it has no `$.gemini`, so a call on it throws a TypeError, and
60 * the Gemini members are skipped. The validator refuses `$.gemini` read as a value, so the call itself is
61 * the test.
62 */
63function notInstalled(err: unknown): boolean {
64 return err instanceof TypeError
65}
66
67async function enrollGemini($: EngineInterface): Promise<void> {
68 try {
69 await $.gemini.enroll({ consumer: CONSUMER, defaultModel: GEMINI_ENROLL_MODEL, ownModels: true })
70 } catch (err) {
71 if (!notInstalled(err)) $.ui.log(`gemini-core refused the enrollment, so the Gemini members will fail: ${errorText(err)}`)
72 }
73}
74
75async function geminiReach($: EngineInterface): Promise<Reach> {
76 try {
77 const s = await $.gemini.settings({ consumer: CONSUMER })
78 return s.hasKey ? { ok: true } : { ok: false, why: 'gemini-core has no key' }
79 } catch (err) {
80 return { ok: false, why: notInstalled(err) ? 'gemini-core is not installed' : `gemini-core did not answer: ${errorText(err)}` }
81 }
82}
83
84/** One run: the mod's state, the run as drawn, what it knows, and the chair's model. */
85type Job = { state: State; run: Run; ctx: Ctx; model: string }
86
87/** The run's section in the sidebar; false while the pane is closed, the sidebar mod is not installed, or a newer run holds it. */
88async function drawRun($: EngineInterface, job: Job): Promise<boolean> {
89 if (job.state.current !== job.run.id) return false
90 try {
91 // The pane draws the consumer in front of the title, so the title does not repeat it.
92 return await $.sidebar.set({ ...SECTION, title: 'run', lines: runLines(job.run, await $.clock.now()), until: 'session', order: 7 })
93 } catch {
94 // The sidebar mod is not installed; the run's end writes one log line instead.
95 return false
96 }
97}
98
99/** The run's end: the section when the sidebar takes it, else one log line. */
100async function toPerson($: EngineInterface, job: Job): Promise<void> {
101 if (!(await drawRun($, job))) $.ui.log(summaryText(job.run))
102}
103
104function viaOf(member: Member, ctx: Ctx): Via {
105 if (member.kind === 'gemini') return 'gemini'
106 return ctx.messages !== undefined && isSessionModel(member, ctx.sessionModel) ? 'fork' : 'complete'
107}
108
109function seatOf(member: Member, ctx: Ctx): Seat {
110 const via = viaOf(member, ctx)
111 if (member.kind === 'gemini' && !ctx.gemini.ok) return { label: member.label, via, state: 'skipped', why: ctx.gemini.why }
112 return { label: member.label, via, state: 'running' }
113}
114
115/** The chair's model: the session's without its window mark, or before its first request the first Claude member. */
116function chairModel(ctx: Ctx, members: readonly Member[]): string {
117 if (ctx.sessionModel !== undefined) return bare(ctx.sessionModel)
118 return members.find(m => m.kind === 'claude')?.id ?? 'claude-opus-5-5'
119}
120
121/** The conversation as one member reads it; Haiku 4.5 reads less, because its window is smaller. */
122function transcriptOf(ctx: Ctx, member: Member): string | undefined {
123 if (ctx.messages === undefined) return undefined
124 const cap = member.id.includes('haiku') ? Math.min(ctx.config.maxInputChars, HAIKU_MAX_CHARS) : ctx.config.maxInputChars
125 const cached = ctx.rendered.get(cap)
126 if (cached !== undefined) return cached
127 const text = renderTranscript(ctx.messages, cap)
128 ctx.rendered.set(cap, text)
129 return text
130}
131
132function reasonText(r: Exclude<ModelForkResult, { isAnswered: true }>): string {
133 return r.reason === 'api-error' ? `api-error ${r.status ?? 'without a response'} ${r.error}` : r.reason
134}
135
136function fromModel(r: ModelForkResult, ms: number): Asked {
137 if (!r.isAnswered) return { why: reasonText(r) }
138 const u = r.usage
139 return { answer: { text: r.text.trim(), ms, inTokens: u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens, outTokens: u.output_tokens } }
140}
141
142/**
143 * A Claude member: a fork of the session when it runs on the session's model, else a completion over the
144 * conversation as text. A session with nothing to fork yet gets the completion, and the seat says so.
145 */
146async function askClaude($: EngineInterface, member: Member, seat: Seat, ctx: Ctx): Promise<Asked> {
147 const started = await $.clock.now()
148 if (seat.via === 'fork') {
149 const forked = await $.model.fork({ prompt: forkMemberPrompt(ctx.question) })
150 if (forked.isAnswered || forked.reason !== 'nothing-to-fork') return fromModel(forked, (await $.clock.now()) - started)
151 seat.via = 'complete'
152 }
153 const prompt = memberPrompt(transcriptOf(ctx, member), ctx.question)
154 const r = await $.model.complete({ model: member.id, system: MEMBER_SYSTEM, prompt, effort: 'high', maxTokens: MAX_TOKENS, timeoutMs: MEMBER_MS })
155 return fromModel(r, (await $.clock.now()) - started)
156}
157
158type GeminiGot = { text: string; inTokens: number; outTokens: number; freeTier: boolean } | { why: string }
159
160/**
161 * One Gemini request through gemini-core, with the next key after a 429. After a 503 the same request
162 * goes again without the wait gemini-core names: `$.clock.sleep` counts against the hook's 10 s budget
163 * (measured on 2.1.283), and the waits of members asked at once add up.
164 */
165async function askGeminiModel($: EngineInterface, body: Record<string, unknown>, model: string): Promise<GeminiGot> {
166 const prepared = await $.gemini.request({ consumer: CONSUMER, body, model })
167 if ('error' in prepared) return { why: prepared.error }
168 if (prepared.model !== model) return { why: 'gemini-core is older than 0.3.0 and sends every request to one model; update it' }
169 const started = await $.clock.now()
170 let http = prepared.http
171 for (let attempt = 1; ; attempt++) {
172 const r = await $.http.fetch(http.url, http.init)
173 const read = await $.gemini.read({ http, status: r.status, ok: r.ok, text: r.text, attempt, elapsedMs: (await $.clock.now()) - started, deadlineMs: MEMBER_MS })
174 if ('error' in read) return { why: read.error }
175 if ('answer' in read) {
176 const cut = read.answer.finishReason === 'MAX_TOKENS' ? '\n[the answer was cut at the output token limit]' : ''
177 return { text: `${read.answer.text.trim()}${cut}`, inTokens: read.answer.inputTokens, outTokens: read.answer.outputTokens, freeTier: prepared.tier === 'free' }
178 }
179 if ('next' in read) http = read.next
180 }
181}
182
183async function askGemini($: EngineInterface, member: Member, ctx: Ctx): Promise<Asked> {
184 const started = await $.clock.now()
185 const text = `${MEMBER_SYSTEM}\n\n${memberPrompt(transcriptOf(ctx, member), ctx.question)}`
186 const got = await askGeminiModel($, { contents: [{ role: 'user', parts: [{ text }] }], generationConfig: { maxOutputTokens: MAX_TOKENS } }, member.id)
187 if ('why' in got) return got
188 return { answer: { ...got, ms: (await $.clock.now()) - started } }
189}
190
191/** Asks one member and redraws its seat with the answer or the reason there is none. */
192async function sit($: EngineInterface, job: Job, seat: Seat, member: Member): Promise<Asked> {
193 if (seat.state === 'skipped') return { why: seat.why ?? 'skipped' }
194 let asked: Asked
195 try {
196 asked = member.kind === 'gemini' ? await askGemini($, member, job.ctx) : await askClaude($, member, seat, job.ctx)
197 } catch (err) {
198 asked = { why: errorText(err) }
199 }
200 if ('answer' in asked) {
201 const { ms, inTokens, outTokens, freeTier } = asked.answer
202 Object.assign(seat, { state: 'answered', ms, inTokens, outTokens, ...(freeTier === undefined ? {} : { freeTier }) })
203 } else Object.assign(seat, { state: 'failed', why: asked.why })
204 await drawRun($, job)
205 return asked
206}
207
208/** Each member's row for the caller; the members that answered get a letter, in order. */
209function rowsOf(members: readonly Member[], asked: readonly Asked[]): Row[] {
210 let next = 0
211 return members.map((m, i) => {
212 const a = asked[i] ?? { why: 'not asked' }
213 if ('why' in a) return { label: m.label, why: a.why }
214 return { letter: letterOf(next++), label: m.label, ms: a.answer.ms, text: a.answer.text }
215 })
216}
217
218async function askChair($: EngineInterface, job: Job, answers: readonly Lettered[]): Promise<ModelForkResult> {
219 const prompt = chairPrompt(job.ctx.question, answers, job.ctx.messages !== undefined)
220 if (job.run.chair.via === 'fork') {
221 const forked = await $.model.fork({ prompt })
222 if (forked.isAnswered || forked.reason !== 'nothing-to-fork') return forked
223 Object.assign(job.run.chair, { via: 'complete', label: labelOfModel(job.model) })
224 }
225 return $.model.complete({ model: job.model, prompt, effort: 'high', maxTokens: MAX_TOKENS, timeoutMs: MEMBER_MS })
226}
227
228/**
229 * The chair as the person reads it. A fork runs on the session's model, which is not known after a module
230 * reload until the next main-loop request, so the label then names the session and not the fallback model.
231 */
232function chairOf(ctx: Ctx, model: string): Chair {
233 if (ctx.messages === undefined) return { label: labelOfModel(model), via: 'complete', state: 'waiting' }
234 return { label: ctx.sessionModel === undefined ? 'the model of the session' : labelOfModel(ctx.sessionModel), via: 'fork', state: 'waiting' }
235}
236
237/** The chair's verdict over the lettered answers, or why there is none; the chair's seat is redrawn either way. */
238async function verdictOf($: EngineInterface, job: Job, answers: readonly Lettered[]): Promise<{ text: string } | { why: string }> {
239 const chair = job.run.chair
240 chair.state = 'running'
241 await drawRun($, job)
242 const started = await $.clock.now()
243 let got: { text: string } | { why: string }
244 try {
245 const r = await askChair($, job, answers)
246 got = r.isAnswered ? { text: r.text.trim() } : { why: reasonText(r) }
247 } catch (err) {
248 got = { why: errorText(err) }
249 }
250 Object.assign(chair, 'text' in got ? { state: 'answered', ms: (await $.clock.now()) - started } : { state: 'failed', why: got.why })
251 if ('text' in got) job.run.verdict = got.text
252 return got
253}
254
255async function contextOf($: EngineInterface, state: State, config: Config, question: string, agentId: string | undefined): Promise<Ctx> {
256 const messages = agentId === undefined ? await $.session.messages() : undefined
257 return {
258 question,
259 config,
260 gemini: await geminiReach($),
261 rendered: new Map(),
262 ...(messages === undefined ? {} : { messages }),
263 ...(state.model === undefined ? {} : { sessionModel: state.model }),
264 }
265}
266
267async function ended($: EngineInterface, job: Job): Promise<void> {
268 job.run.endedAt = await $.clock.now()
269 job.state.last = summaryText(job.run)
270 await toPerson($, job)
271}
272
273/** One council run: every member at once, then the chair over the answers. */
274async function convene($: EngineInterface, state: State, config: Config, question: string, agentId: string | undefined): Promise<Outcome> {
275 const ctx = await contextOf($, state, config, question, agentId)
276 const members = await membersNow($)
277 const model = chairModel(ctx, members)
278 const chair = chairOf(ctx, model)
279 const run: Run = { id: ++state.runs, question, startedAt: await $.clock.now(), seats: members.map(m => seatOf(m, ctx)), chair }
280 const job: Job = { state, run, ctx, model }
281 state.current = run.id
282 await drawRun($, job)
283 const asked = await Promise.all(members.map((m, i) => sit($, job, run.seats[i] as Seat, m)))
284 const rows = rowsOf(members, asked)
285 const answers = rows.flatMap(r => (r.text === undefined ? [] : [{ letter: r.letter ?? '?', text: r.text }]))
286 if (answers.length === 0) {
287 Object.assign(run.chair, { state: 'failed', why: 'no member answered' })
288 await ended($, job)
289 return { deny: noAnswerText(rows) }
290 }
291 const verdict = await verdictOf($, job, answers)
292 await ended($, job)
293 return { text: resultText(verdict, chair.label, rows) }
294}
295
296async function onToolCall($: EngineInterface, state: State, config: Config, input: Record<string, unknown>, agentId: string | undefined): Promise<ToolCallResult> {
297 if (!(await isEnabled($))) return { deny: OFF_DENY }
298 let question: string
299 try {
300 question = questionOf(input)
301 } catch (err) {
302 return { deny: errorText(err) }
303 }
304 const outcome = await convene($, state, config, question, agentId)
305 return 'deny' in outcome ? outcome : { result: outcome.text }
306}
307
308/**
309 * Hands the result to the model through the mod's own `send` command, so the model reads the text alone,
310 * as the person would type it. A turn that runs meanwhile holds it until its end (measured on 2.1.283).
311 * A run the engine refuses sends it as a plugin prompt instead.
312 */
313function send($: EngineInterface, text: string): void {
314 $.command.run({ command: SEND_COMMAND, args: text }).catch((err: unknown) => {
315 $.ui.log(`the send command did not run, the verdict goes out as a plugin prompt: ${errorText(err)}`)
316 $.prompt.submit({ text }).catch((e: unknown) => $.ui.log(`the verdict was not sent: ${errorText(e)}`))
317 })
318}
319
320/** /council <question>: the run starts from a timer, because the engine refuses `$.command.run` inside the hook the command waits on. */
321function runManual($: EngineInterface, state: State, config: Config, question: string): void {
322 $.clock.after(0, () => {
323 convene($, state, config, question, undefined).then(
324 outcome => ('text' in outcome ? send($, sendText(question, outcome.text)) : $.ui.log(outcome.deny)),
325 (err: unknown) => $.ui.log(`the council did not run: ${errorText(err)}`),
326 )
327 })
328}
329
330async function statusOf($: EngineInterface, state: State): Promise<string> {
331 const reach = await geminiReach($)
332 const members = (await membersNow($)).map(m => (m.kind === 'gemini' && !reach.ok ? { label: m.label, skipped: reach.why } : { label: m.label }))
333 const chair = state.model === undefined ? 'the model of the session, forked' : `${labelOfModel(state.model)}, the model of the session, forked`
334 return statusText(await isEnabled($), members, chair, state.last)
335}
336
337async function runCommand($: EngineInterface, state: State, config: Config, args: string): Promise<string> {
338 const command = parseCommand(args)
339 switch (command.kind) {
340 case 'error': return command.text
341 case 'status': return statusOf($, state)
342 case 'set': return storeEnabled($, state, command.enabled)
343 case 'members': return membersText((await membersNow($)).map(m => m.label))
344 case 'setMembers':
345 await $.store.set(MEMBERS_KEY, command.members)
346 return membersText((await membersNow($)).map(m => m.label))
347 case 'resetMembers':
348 await $.store.delete(MEMBERS_KEY)
349 return membersText(storedMembers(DEFAULT_MEMBERS).map(m => m.label))
350 case 'ask':
351 runManual($, state, config, command.question)
352 return convenedText((await membersNow($)).length)
353 }
354}
355
356export const register: Register = (on, options) => {
357 const config = configFrom(options)
358 const state: State = { guidance: false, declared: false, runs: 0 }
359
360 on('session.start', async ($, e, next) => {
361 const r = await next(e)
362 await $.command.register({
363 name: 'council',
364 description: 'A council of models on a hard problem: status, on, off, members [models | reset], or a question to ask it (council)',
365 argumentHint: '[on | off | members [models | reset] | <question>]',
366 })
367 await enrollGemini($)
368 state.guidance = await isEnabled($)
369 if (state.guidance) await declareTool($, state)
370 return r
371 })
372
373 // Every window shares the store: a /council on made in another window declares the tool here before the
374 // next turn's first request. The note waits for /clear, as after /council on in this window.
375 on('turn.start', async ($, e, next) => {
376 if (!state.declared && (await isEnabled($))) await declareTool($, state)
377 return next(e)
378 })
379
380 // /clear arrives only through the classic seam; the note follows the setting from there.
381 on('classic.SessionStart', async ($, e, next) => {
382 const r = await next(e)
383 if (e.agent_id === undefined) state.guidance = await isEnabled($)
384 return r
385 })
386
387 on('command.run', { command: 'council' }, async ($, e) => ({ text: await runCommand($, state, config, String(e.args ?? '')) }))
388
389 // The note is fixed at a session start or /clear, so a change of the setting does not change the prompt the cache holds.
390 on('prompt.section', { name: 'env_info_simple' }, async (_, e, next) => {
391 const r = await next(e)
392 return r.text === null || !state.guidance ? r : { text: `${r.text}\n\n${SYSTEM_GUIDANCE}` }
393 })
394
395 // The tool matchers are patterns that equal TOOL_ID, because a literal typechecks only while the tool is
396 // declared in the session /plugin-types ran in, and the tool is declared only while the council is on.
397
398 // A plugin's tool waits behind ToolSearch by default; this one is listed, so the model can call it at once.
399 on('tool.describe', { tool: /^mcp__council__convene$/ }, async (_, e, next) => ({ ...(await next(e)), isDeferred: false }))
400
401 on('tool.call', { tool: /^mcp__council__convene$/ }, async ($, e) => onToolCall($, state, config, e as Record<string, unknown>, e.agentId))
402
403 // The main loop's model decides which member forks the session and which model chairs.
404 on('turn.step', async function* (_, e, next) {
405 if (e.agentId === undefined) state.model = e.model
406 return yield* next(e)
407 })
408}
409hooks/board.ts 115 lines1/** A council run as the person sees it: the sidebar section, the one log line, and the /council status. */
2
3/** How a member is asked: a fork of the session, a completion with the conversation as text, or gemini-core. */
4export type Via = 'fork' | 'complete' | 'gemini'
5
6export type SeatState = 'running' | 'answered' | 'failed' | 'skipped'
7
8/** One member's place in a run. */
9export type Seat = { label: string; via: Via; state: SeatState; ms?: number; inTokens?: number; outTokens?: number; why?: string; freeTier?: boolean }
10
11export type ChairState = 'waiting' | 'running' | 'answered' | 'failed'
12
13export type Chair = { label: string; via: 'fork' | 'complete'; state: ChairState; ms?: number; why?: string }
14
15/** A run from start to verdict; `endedAt` is set once the chair has answered or failed. */
16export type Run = { id: number; question: string; startedAt: number; seats: Seat[]; chair: Chair; verdict?: string; endedAt?: number }
17
18/** A sidebar line, as the sidebar contract spells it; kept here so this file stays free of `$`. */
19export type Kind = 'ok' | 'warn' | 'error' | 'dim' | 'info'
20export type Part = { text: string; kind?: Kind }
21export type Line = { text: string; kind?: Kind; parts?: readonly Part[] }
22
23function line(parts: readonly Part[]): Line {
24 return { text: parts.map(p => p.text).join(''), parts }
25}
26
27function tokens(n: number): string {
28 return n >= 1000 ? `${(n / 1000).toFixed(n >= 10_000 ? 0 : 1)}k` : String(n)
29}
30
31function secs(ms: number): string {
32 return `${Math.round(ms / 1000)}s`
33}
34
35function cut(text: string, max: number): string {
36 const flat = text.replace(/\s+/g, ' ').trim()
37 return flat.length <= max ? flat : `${flat.slice(0, max - 1)}…`
38}
39
40const SEAT_KIND: Record<SeatState, Kind> = { running: 'warn', answered: 'ok', failed: 'error', skipped: 'dim' }
41
42/** What follows a seat's state word: the time and tokens of an answer, or why there is none. */
43function seatTail(s: Seat): string {
44 if (s.state === 'answered') return ` ${secs(s.ms ?? 0)} · ${tokens(s.inTokens ?? 0)} in, ${tokens(s.outTokens ?? 0)} out${s.freeTier === true ? ' · free tier' : ''}`
45 return s.why === undefined ? '' : `: ${s.why}`
46}
47
48/**
49 * The model's colour by family, as session-watch and subagent-ledger give it: opus red, fable yellow,
50 * sonnet green, haiku faint, and the whole Gemini family blue.
51 */
52function modelTone(label: string): Kind | undefined {
53 const families: [RegExp, Kind][] = [[/opus/i, 'error'], [/fable/i, 'warn'], [/sonnet/i, 'ok'], [/haiku/i, 'dim'], [/^(?:gemini|gemma)-/i, 'info']]
54 return families.find(([family]) => family.test(label))?.[1]
55}
56
57/** A model's label as a part, coloured by its family. */
58function modelPart(label: string): Part {
59 const kind = modelTone(label)
60 return kind === undefined ? { text: label } : { text: label, kind }
61}
62
63function seatLine(s: Seat): Line {
64 return line([modelPart(s.label), { text: ` · ${s.via} · ` }, { text: s.state, kind: SEAT_KIND[s.state] }, { text: seatTail(s) }])
65}
66
67const CHAIR_WORD: Record<ChairState, { text: string; kind: Kind }> = {
68 waiting: { text: 'waiting', kind: 'dim' },
69 running: { text: 'writing', kind: 'warn' },
70 answered: { text: 'done', kind: 'ok' },
71 failed: { text: 'failed', kind: 'error' },
72}
73
74function chairLine(c: Chair): Line {
75 const word = CHAIR_WORD[c.state]
76 const tail = c.state === 'answered' ? ` ${secs(c.ms ?? 0)}` : c.why === undefined ? '' : `: ${c.why}`
77 return line([{ text: 'chair · ' }, modelPart(c.label), { text: ` · ${c.via} · ` }, word, { text: tail }])
78}
79
80function headLine(run: Run, now: number): Line {
81 const state = run.endedAt === undefined ? { text: `running ${secs(now - run.startedAt)}`, kind: 'warn' as const } : { text: `done in ${secs(run.endedAt - run.startedAt)}`, kind: 'ok' as const }
82 return line([{ text: cut(run.question, 60), kind: 'dim' }, { text: ' · ' }, state])
83}
84
85/** The verdict's first words as plain text: markdown marks and a leading "verdict" title left out. */
86function plainVerdict(text: string): string {
87 return text.replace(/[*_`#>]/g, '').replace(/\s+/g, ' ').trim().replace(/^(?:council )?verdict:?\s*/i, '')
88}
89
90/** The section's lines: the question, each member, the chair, and the verdict's first words once there is one. */
91export function runLines(run: Run, now: number): Line[] {
92 const verdict = run.verdict === undefined ? [] : [{ text: `verdict: ${cut(plainVerdict(run.verdict), 80)}` }]
93 return [headLine(run, now), ...run.seats.map(seatLine), chairLine(run.chair), ...verdict]
94}
95
96function answeredCount(run: Run): number {
97 return run.seats.filter(s => s.state === 'answered').length
98}
99
100/** One line for a closed sidebar, and the last run in the status. */
101export function summaryText(run: Run): string {
102 const took = run.endedAt === undefined ? 'still running' : `in ${secs(run.endedAt - run.startedAt)}`
103 const chair = run.chair.state === 'answered' ? 'the chair wrote the verdict' : `the chair wrote none: ${run.chair.why ?? 'no member answered'}`
104 return `${answeredCount(run)} of ${run.seats.length} members answered ${took}; ${chair}`
105}
106
107/** A member in the status: its label, and why a run would skip it. */
108export type StatusMember = { label: string; skipped?: string }
109
110export function statusText(enabled: boolean, members: readonly StatusMember[], chair: string, last: string | undefined): string {
111 const head = enabled ? 'on: the model can call the council when it is stuck' : 'off: the model cannot call the council; /council <question> still runs'
112 const names = members.map(m => (m.skipped === undefined ? m.label : `${m.label} (skipped: ${m.skipped})`)).join(', ')
113 return [head, `members: ${names}`, `chair: ${chair}`, ...(last === undefined ? [] : [`last: ${last}`])].join('\n')
114}
115hooks/command.ts 47 lines1/** The argument of /council and the lines it prints. */
2import { parseMembers } from './members.ts'
3
4export const ENABLED_KEY = 'enabled'
5export const MEMBERS_KEY = 'members'
6
7export type Command =
8 | { kind: 'status' }
9 | { kind: 'set'; enabled: boolean }
10 | { kind: 'members' }
11 | { kind: 'setMembers'; members: string[] }
12 | { kind: 'resetMembers' }
13 | { kind: 'ask'; question: string }
14 | { kind: 'error'; text: string }
15
16function membersCommand(rest: readonly string[]): Command {
17 if (rest.length === 0) return { kind: 'members' }
18 if (rest.length === 1 && rest[0] === 'reset') return { kind: 'resetMembers' }
19 const parsed = parseMembers(rest)
20 return 'error' in parsed ? { kind: 'error', text: parsed.error } : { kind: 'setMembers', members: parsed.members }
21}
22
23/** `on`, `off`, `status` and `members ...` are commands; any other text is a question for the council. */
24export function parseCommand(args: string): Command {
25 const text = args.trim()
26 if (text === '' || text === 'status') return { kind: 'status' }
27 if (text === 'on' || text === 'off') return { kind: 'set', enabled: text === 'on' }
28 const [first, ...rest] = text.split(/\s+/)
29 return first === 'members' ? membersCommand(rest) : { kind: 'ask', question: text }
30}
31
32export function changeText(enabled: boolean): string {
33 return enabled
34 ? 'on: the model can call the council tool now; the system prompt note that says when comes at /clear or the next session'
35 : 'off: a call answers that the council is off; the note leaves at /clear or the next session, the tool at the next session; /council <question> still runs'
36}
37
38export function membersText(labels: readonly string[]): string {
39 return `members: ${labels.join(', ')}`
40}
41
42export function convenedText(count: number): string {
43 return `convened: ${count} members; the verdict comes as a message when they have answered`
44}
45
46export const OFF_DENY = 'the council is off; the user can turn it on with /council on'
47hooks/config.ts 26 lines1/** The plugin option and the fixed limits of one council run. */
2import type { PluginOptions } from 'claude-code'
3
4export type Config = { maxInputChars: number }
5
6/**
7 * 400,000 characters of rendered conversation were 163,828 input tokens on Opus 5.5, Sonnet 5 and
8 * Fable 5.1, 128,018 on Haiku 4.5 and 120,057 on gemini-3.8-flash (measured on 2.1.283).
9 */
10export const DEFAULTS: Config = { maxInputChars: 400_000 }
11
12/** Haiku 4.5 has a 200k token window; 560,000 characters are about 180k of its tokens. */
13export const HAIKU_MAX_CHARS = 560_000
14
15/** A member that has not answered by then is counted as failed; it guards against a hung request only. */
16export const MEMBER_MS = 300_000
17
18/** The longest answer of a member or the chair, thinking included. */
19export const MAX_TOKENS = 8192
20
21export function configFrom(options: PluginOptions): Config {
22 const value = options.maxInputChars
23 const ok = typeof value === 'number' && Number.isInteger(value) && value >= 20_000 && value <= 2_000_000
24 return { maxInputChars: ok ? value : DEFAULTS.maxInputChars }
25}
26hooks/members.ts 78 lines1/** The council's members: how a typed token names a model, the defaults, and the labels the person reads. */
2
3/** A Claude model asked through the session's own client, or a Gemini model asked through gemini-core. */
4export type Member = { kind: 'claude' | 'gemini'; id: string; label: string }
5
6const ALIASES = new Map([
7 ['opus', 'claude-opus-5-5'],
8 ['sonnet', 'claude-sonnet-5-5'],
9 ['fable', 'claude-fable-5-1'],
10 ['haiku', 'claude-haiku-4-5-20251001'],
11])
12
13const LABELS = new Map([
14 ['claude-opus-5-5', 'opus 5.5'],
15 ['claude-sonnet-5-5', 'sonnet 5.5'],
16 ['claude-sonnet-5', 'sonnet 5'],
17 ['claude-fable-5-1', 'fable 5.1'],
18 ['claude-haiku-4-5-20251001', 'haiku 4.5'],
19])
20
21/**
22 * The members of a fresh install. `gemini-3.1-pro-preview` is left out, because a free-tier key has no
23 * quota for it: all 34 free keys of a check answered HTTP 429 (measured on 2.1.283).
24 */
25export const DEFAULT_MEMBERS: readonly string[] = ['opus', 'sonnet', 'fable', 'haiku', 'gemini-3.8-flash']
26
27/** A model id goes into a URL path for Gemini, so only a plain id is taken. */
28const MODEL_ID = /^[a-z0-9][a-z0-9.-]{0,79}$/
29
30const GEMINI_PREFIX = /^(?:gemini|gemma)-/
31
32export const MEMBER_HINT = 'use opus, sonnet, fable, haiku, a claude- model id, or a gemini- or gemma- model id'
33
34/** What a typed token names, or undefined when it names no model. */
35export function memberOf(token: string): Member | undefined {
36 const id = ALIASES.get(token) ?? token
37 if (!MODEL_ID.test(id)) return undefined
38 const label = LABELS.get(id) ?? id
39 if (id.startsWith('claude-')) return { kind: 'claude', id, label }
40 return GEMINI_PREFIX.test(id) ? { kind: 'gemini', id, label } : undefined
41}
42
43/** The tokens of `/council members <tokens>`, each once, or why they are refused. */
44export function parseMembers(tokens: readonly string[]): { members: string[] } | { error: string } {
45 if (tokens.length === 0) return { error: `members takes one or more models: ${MEMBER_HINT}` }
46 const unknown = tokens.find(t => memberOf(t) === undefined)
47 if (unknown !== undefined) return { error: `${unknown} is not a model: ${MEMBER_HINT}` }
48 return { members: [...new Set(tokens)] }
49}
50
51/** The stored member tokens, or the defaults when the store holds none that name a model. */
52export function storedMembers(value: unknown): Member[] {
53 const tokens = Array.isArray(value) ? value.filter((t): t is string => typeof t === 'string') : []
54 const members = tokens.map(memberOf).filter((m): m is Member => m !== undefined)
55 return members.length > 0 ? members : DEFAULT_MEMBERS.map(memberOf).filter((m): m is Member => m !== undefined)
56}
57
58/** A model id without the context-window mark the engine adds (`claude-opus-5-5[1m]`). */
59export function bare(model: string): string {
60 return model.replace(/\[[^\]]*\]$/, '')
61}
62
63/** A model id without its release date (`claude-haiku-4-5-20251001`). */
64function undated(model: string): string {
65 return bare(model).replace(/-\d{8}$/, '')
66}
67
68/** Whether a member is the model the session's main loop runs on, dated id or not. */
69export function isSessionModel(member: Member, sessionModel: string | undefined): boolean {
70 if (member.kind !== 'claude' || sessionModel === undefined) return false
71 return undated(member.id) === undated(sessionModel)
72}
73
74/** The label of a model the session runs on, as a member of that model would read. */
75export function labelOfModel(model: string): string {
76 return LABELS.get(bare(model)) ?? bare(model)
77}
78hooks/prompts.ts 140 lines1/** The convene tool as the model sees it, what each member and the chair are asked, and the answer the caller reads. */
2
3export const TOOL_NAME = 'convene'
4
5export const TOOL_ID = `mcp__council__${TOOL_NAME}` as const
6
7export const INPUT_SCHEMA = {
8 type: 'object',
9 properties: {
10 question: {
11 type: 'string',
12 description: 'The problem, what you tried and what you found, and the specific question you want the council to answer.',
13 },
14 },
15 required: ['question'],
16}
17
18const WHEN = [
19 'Call it when you are stuck, not for routine work:',
20 '- the same error survived two attempts to fix it;',
21 '- the root cause is still unclear after you investigated;',
22 '- you must choose between two designs that each have real trade-offs;',
23 '- before a change that is hard to undo.',
24 'Skip it for simple, mechanical steps. The answers can be wrong: check them against the code before you act, and tell the user where you disagree.',
25]
26
27/** What the model reads about the tool. It names no model, so a change of the members does not make it stale. */
28export const TOOL_DESCRIPTION = [
29 'Convene a council of models on a hard problem. Several models read the conversation so far and your question and answer on their own; then the model of this session, as the chair, writes one verdict from their answers. You get the verdict and every answer. A call takes about half a minute.',
30 ...WHEN,
31].join('\n')
32
33/** The system prompt's note about the tool; the text is fixed, so the prompt cache holds. */
34export const SYSTEM_GUIDANCE = [
35 '# Council',
36 `You have a council tool, ${TOOL_ID}: several models answer your question on their own and the model of this session writes one verdict from their answers. When it is listed only by name, load it with ToolSearch (query "select:${TOOL_ID}").`,
37 ...WHEN,
38].join('\n')
39
40const ANSWER_RULES = `Answer on your own; the other members do not see your answer:
41- Answer the question directly first.
42- Name the evidence for each claim: a file, an output, a message of the conversation.
43- Point out mistakes, risks, missed steps and wrong assumptions. Say so when the plan is sound; do not invent problems.
44- Prefer concrete next steps over general advice. Keep it short.
45- Do not state as fact what the conversation does not show; say what the agent should check instead.
46- Answer in the language of the question.`
47
48/** A member's system prompt when it reads the conversation as text. */
49export const MEMBER_SYSTEM = `You are one member of a council of models that advises a coding agent (Claude, in Claude Code) working with a user. You get the agent's conversation so far (the user's messages, the agent's replies, each tool call with its output) and then the agent's question.
50
51${ANSWER_RULES}`
52
53/** A member's prompt: the conversation when there is one, then the question. */
54export function memberPrompt(transcript: string | undefined, question: string): string {
55 const conversation = transcript === undefined ? 'The conversation is not available; only the question is.' : `The conversation:\n\n${transcript}`
56 return `${conversation}\n\nThe agent asks the council:\n\n${question}`
57}
58
59/** A member's prompt when it forks the session and reads the conversation itself. */
60export function forkMemberPrompt(question: string): string {
61 return `You are now one member of a council of models that the agent convened. Do not call any tool; answer in text. The conversation above is the one to judge.
62
63${ANSWER_RULES}
64
65The question:
66
67${question}`
68}
69
70/** One member's answer as the chair reads it: a letter in place of the model's name. */
71export type Lettered = { letter: string; text: string }
72
73export function letterOf(index: number): string {
74 return String.fromCharCode(65 + index)
75}
76
77/** The chair's prompt; the members are letters, so the chair judges the answers and not the models. */
78export function chairPrompt(question: string, answers: readonly Lettered[], hasConversation: boolean): string {
79 const context = hasConversation ? 'the evidence in this conversation' : 'the question and the answers'
80 const body = answers.map(a => `Member ${a.letter}:\n${a.text}`).join('\n\n')
81 return `You chair a council of models that the agent convened. Do not call any tool; answer in text. ${answers.length} members answered the question below on their own; their answers follow, named by letter.
82
83Write the council's verdict for the agent:
84- where the members agree;
85- where they disagree, and which side ${context} supports;
86- what the members missed;
87- the next step you recommend.
88Start with the verdict itself, without a title. Name members by letter. Keep it short. Answer in the language of the question.
89
90The question:
91
92${question}
93
94${body}`
95}
96
97/** How one member's run ended, as the caller reads it. */
98export type Row = { letter?: string; label: string; ms?: number; text?: string; why?: string }
99
100function seconds(ms: number): string {
101 return `${Math.round(ms / 1000)} s`
102}
103
104function rowText(r: Row): string {
105 if (r.text === undefined) return `## ${r.label} · no answer: ${r.why ?? 'unknown'}`
106 return `## ${r.letter ?? '?'}: ${r.label} · ${seconds(r.ms ?? 0)}\n\n${r.text}`
107}
108
109/** The verdict when the chair wrote one, and every member's answer or why it gave none. */
110export function resultText(verdict: { text: string } | { why: string }, chair: string, rows: readonly Row[]): string {
111 const answered = rows.filter(r => r.text !== undefined).length
112 const head = 'text' in verdict
113 ? `Council verdict (${answered} of ${rows.length} members answered; the chair, ${chair}, wrote it):\n\n${verdict.text}`
114 : `The chair, ${chair}, wrote no verdict (${verdict.why}); ${answered} of ${rows.length} members answered.`
115 return `${head}\n\nMember answers:\n\n${rows.map(rowText).join('\n\n')}`
116}
117
118/** The refusal when no member answered, naming each reason. */
119export function noAnswerText(rows: readonly Row[]): string {
120 return `the council got no answer: ${rows.map(r => `${r.label}: ${r.why ?? 'unknown'}`).join('; ')}`
121}
122
123/** The prompt that hands a /council run's result to the model, in the person's voice. */
124export function sendText(question: string, result: string): string {
125 return `I convened the council with /council. Read the verdict and the answers, check them against the code, and tell me what you take from them and what you would do next.
126
127My question:
128
129${question}
130
131${result}`
132}
133
134/** The question from the tool input; a missing or empty one throws. */
135export function questionOf(input: Record<string, unknown>): string {
136 const question = input.question
137 if (typeof question !== 'string' || question.trim() === '') throw new Error('question is required: the problem, what you tried, and what the council should answer')
138 return question.trim()
139}
140hooks/transcript.ts 101 lines1/** The conversation as a council member reads it, cut to a character limit. */
2import type { SessionMessage, ToolUseSummary } from 'claude-code'
3
4type Output = { text: string; isError: boolean }
5
6/** The output of each tool call, by tool_use_id: its result block, else the call's own text. */
7function outputs(messages: readonly SessionMessage[]): Map<string, Output> {
8 const out = new Map<string, Output>()
9 for (const m of messages) {
10 for (const use of m.toolUses) out.set(use.tool_use_id, { text: use.text ?? '', isError: use.isError === true })
11 }
12 for (const m of messages) {
13 for (const r of m.toolResults ?? []) out.set(r.tool_use_id, { text: r.text, isError: r.isError })
14 }
15 return out
16}
17
18/** Cuts a text to about `max` characters: its head and its tail around one note. */
19export function clip(text: string, max: number): string {
20 if (text.length <= max) return text
21 const half = Math.max(0, Math.floor(max / 2))
22 return `${text.slice(0, half)}\n[… ${text.length - 2 * half} chars omitted …]\n${text.slice(text.length - half)}`
23}
24
25function callLines(use: ToolUseSummary, output: Output | undefined, cap: number): string[] {
26 const status = output?.isError === true ? 'error' : 'output'
27 return [` [call] ${use.tool} ${JSON.stringify(use.input) ?? '{}'}`, ` ${status}: ${clip(output?.text ?? '', cap)}`]
28}
29
30/** One message's lines, numbered by its place in the whole conversation. */
31function messageText(m: SessionMessage, n: number, byUse: ReadonlyMap<string, Output>, cap: number): string {
32 return [`#${n} ${m.role}: ${m.text}`, ...m.toolUses.flatMap(use => callLines(use, byUse.get(use.tool_use_id), cap))].join('\n')
33}
34
35function render(messages: readonly SessionMessage[], first: number, byUse: ReadonlyMap<string, Output>, cap: number): string {
36 return messages.map((m, i) => messageText(m, first + i + 1, byUse, cap)).join('\n')
37}
38
39/** The output lengths of one message's calls. */
40function outputLengths(m: SessionMessage, byUse: ReadonlyMap<string, Output>): number[] {
41 return m.toolUses.map(u => byUse.get(u.tool_use_id)?.text.length ?? 0)
42}
43
44/** The size of a message with every output cut to `cap`, as `clip` cuts it (the note is about 40 characters). */
45function sizeAt(fixed: number, lengths: readonly number[], cap: number): number {
46 return fixed + lengths.reduce((sum, n) => sum + Math.min(n, cap + 40), 0)
47}
48
49/** The longest output length that makes the messages fit, found by bisection; undefined when none does. */
50function outputCap(fixed: number, lengths: readonly number[], max: number): number | undefined {
51 if (sizeAt(fixed, lengths, 0) > max) return undefined
52 let low = 0
53 let high = Math.max(0, ...lengths)
54 while (low < high) {
55 const mid = Math.ceil((low + high) / 2)
56 if (sizeAt(fixed, lengths, mid) <= max) low = mid
57 else high = mid - 1
58 }
59 return low
60}
61
62type Sized = { fixed: number; lengths: number[] }
63
64/** Each message's length without its outputs, and its outputs' lengths. */
65function sizes(messages: readonly SessionMessage[], byUse: ReadonlyMap<string, Output>): Sized[] {
66 return messages.map((m, i) => {
67 const lengths = outputLengths(m, byUse)
68 const full = messageText(m, i + 1, byUse, Infinity).length + 1
69 return { fixed: full - lengths.reduce((a, b) => a + b, 0), lengths }
70 })
71}
72
73/** How many of the oldest messages to leave out, so the rest fits with every output cut to its shortest. */
74function dropped(sized: readonly Sized[], max: number): number {
75 let total = sized.reduce((sum, s) => sum + sizeAt(s.fixed, s.lengths, 0), 0)
76 let first = 0
77 while (first < sized.length - 1 && total > max) {
78 total -= sizeAt(sized[first]?.fixed ?? 0, sized[first]?.lengths ?? [], 0)
79 first++
80 }
81 return first
82}
83
84/**
85 * Every message in order, each tool call with its input and output. Over `maxChars`, the longest outputs
86 * are cut to one common length; when the conversation is over it even without output, the oldest
87 * messages are left out and a first line says how many, and a last message alone over it is cut.
88 */
89export function renderTranscript(messages: readonly SessionMessage[], maxChars: number): string {
90 const byUse = outputs(messages)
91 const full = render(messages, 0, byUse, Infinity)
92 if (full.length <= maxChars) return full
93 const sized = sizes(messages, byUse)
94 const first = dropped(sized, maxChars - 60)
95 const note = first === 0 ? '' : `[the first ${first} messages are left out]\n`
96 const kept = sized.slice(first)
97 const fixed = kept.reduce((sum, s) => sum + s.fixed, 0)
98 const cap = outputCap(fixed, kept.flatMap(s => s.lengths), maxChars - note.length)
99 return clip(`${note}${render(messages.slice(first), first, byUse, cap ?? 0)}`, maxChars)
100}
101