Sends Claude Code's suggested next prompt to Let Them Talk, so its Send box shows the same suggestion, runs a Chat in IDE started from it on the model picked…

A Claude Code plugin for Let Them Talk, a visual board for your Claude Code sessions. It does three things:
/effort status so the board can read the switch.It needs Let Them Talk running on port 8765, the default. It talks only to the app on your own machine, and does nothing when the app isn't running.
claude plugin marketplace add abdullahkavakli/let-them-talk
claude plugin install let-them-talk-suggestions@let-them-talk
Or, inside a chat: /plugin install let-them-talk-suggestions --marketplace abdullahkavakli/let-them-talk.
The plugin is the mod of the latest release, the same release as the app uvx let-them-talk runs, so the two match. A change made to the mod since then reaches you with the next release.
Chats started before the plugin was installed show no suggestion until they are restarted.
More, including how to load it from a copy of the repository (which then follows that copy, not the release) and what to do if the app runs on another port: Configuration.
hooks/register.ts 229 lines1import type { Register } from 'claude-code'
2
3// Let Them Talk (this repo) shows each chat's suggested next prompt in its
4// Send box: exactly what this chat's own prompt box shows, or nothing.
5//
6// - When Claude Code shows its own suggestion, it is passed on.
7// - When it made none (the terminal is unfocused, or a background agent has
8// nobody attached) and Let Them Talk has this chat open, one is made the
9// way Claude Code makes its own ($.model.fork over the cached conversation)
10// and shown in this chat's prompt box too, so both match.
11// - A background agent that ends its turn waiting on you is left alone:
12// Claude Code makes that one itself and saves it, and the app reads it there.
13// Only text the box really shows is reported; otherwise "none".
14//
15// It also runs a chat on the model picked in the app's New agent, for a Chat in
16// IDE: the editor link that opens one can't name a model, so the app says
17// which when the chat's first turns start, and this names it on the chat's
18// own requests (not its subagents'). A model you then pick in the chat wins.
19//
20// And in a background agent it keeps the app's Ultracode line true. The app
21// reads ultracode from the agent's conversation, where Claude Code notes each
22// /effort answered while it is idle. One run while it works (typed, or Tab
23// in its Effort panel) answers on its screen only, which no hook sees. So
24// after an /effort that may have switched it (not an effort level alone) and
25// got no answer, this runs /effort status (a line the agent shows), which
26// Claude Code queues until the agent is idle: its answer lands in the
27// conversation, where the app reads it. Ordinary terminal and IDE chats are
28// left alone: the app shows ultracode for background agents only.
29
30const APP = 'http://localhost:8765' // Let Them Talk's default port (LTT_PORT)
31const HEADERS = { 'Content-Type': 'application/json', 'X-Let-Them-Talk': '1' }
32const TICK_MS = 2000
33const OWN_WAIT_MS = 12000 // Claude Code makes its own within a few seconds of the turn's end
34const WINDOW_MS = 300_000 // after that the conversation may no longer be cached (server: MOD_WINDOW)
35const HELLO_EVERY_MS = 300_000 // so the app keeps counting this chat as having the mod
36const MODEL_ASKS = 3 // turns at a chat's start the app is asked for a model (it knows by the first)
37const ANSWER_WAIT_MS = 1500 // an idle /effort's answer is appended within ms; a busy one's never
38const BACKGROUND = 'bg' // CLAUDE_CODE_SESSION_KIND of a background agent (claude --bg), its registry kind the app reads
39const COMMAND_NAME = /<command-name>\/?([^<]*)<\/command-name>/
40const COMMAND_OUT = /^\s*<local-command-stdout>/ // a command's answer
41const MAY_SWITCH = /^\s*(ultracode(\s|$)|$)/i // /effort ultracode [on|off], or its panel (no words): Claude Code's own reading
42
43const PROMPT = `[Suggestion only. Do not answer this message or continue the task.]
44Predict what the user will most likely type next in this chat. Predict what they
45would type, not what they should do: they should think "I was about to type that".
46If you just asked whether to go on, answer like "yes" or "go ahead"; if you offered
47options, pick the one this user would; if a task is done and the next step is
48obvious, name it, like "commit it" or "run the tests"; after an error or a
49misunderstanding, suggest nothing. Be specific. Never suggest thanks or praise, a
50question, your own voice ("Let me..."), or anything new the user didn't ask for.
51One sentence of 2 to 12 words, in the user's language and style. If the next step
52isn't obvious, reply with a single hyphen. Reply with only the suggestion.`
53
54const SILENT = /^\W*(-|none|nothing|silence|no suggestion.*|nothing to suggest.*|done)\W*$|^[[(].*[\])]$/i
55
56/** What to tell the app about a suggestion: the text only if the box shows it. */
57export function report(text: string | undefined, isShown: boolean, made: 'claude' | 'fork') {
58 return text?.trim() && isShown ? { text, made } : { text: '', made: 'none' as const }
59}
60
61export function cleanSuggestion(text: string): string | undefined {
62 const line = text.trim().split('\n')[0].trim().replace(/^["'“”]+|["'“”]+$/g, '').trim()
63 return line && !SILENT.test(line) && line.split(/\s+/).length <= 12 ? line : undefined
64}
65
66/** The model a request of this chat names: the app's pick (a full id), until
67 * the chat's own model changes under it (you picked another in the chat), then
68 * the chat's. The chat's own spelling of the same model ([1m]) is kept. */
69export function modelFor(wanted: string | undefined, startedOn: string | undefined, now: string) {
70 if (!wanted) return { model: now, startedOn, dropped: false }
71 if (startedOn !== undefined && now !== startedOn) return { model: now, startedOn, dropped: true }
72 return { model: now.startsWith(wanted) ? now : wanted, startedOn: startedOn ?? now, dropped: false }
73}
74
75/** What the mod keeps of the /effort runs in this chat. */
76type Efforts = {
77 lastCommand?: string // the command whose answer is appended next
78 runs: number // /effort runs so far
79 answered: number // the last /effort run whose answer was appended
80 looking: boolean // this mod's /effort status waits for the chat to be idle
81}
82
83export const register: Register = on => {
84 let turn = 0 // main-loop turns ended so far
85 let starts = 0 // main-loop turns started so far
86 let ownFor = -1 // the turn Claude Code's own suggestion came after
87 let pending: { turn: number; starts: number; waited: number } | undefined // a turn still without one
88 let busy = false
89 let helloFor: string | undefined
90 let sinceHello = 0
91 let unsent: string | undefined // a report the app didn't take (it was down), sent again next tick
92 let wanted: string | undefined // the model the app picked for this chat
93 let startedOn: string | undefined // the chat's own model when that took over
94 let asked = 0
95 const efforts: Efforts = { runs: 0, answered: 0, looking: false }
96
97 // Work after a turn outlives the turn's own hooks, so a timer started here does it.
98 on('session.start', async ($, e, next) => {
99 const started = await next(e)
100 // A headless run (claude -p, such as the app's own TL;DR runs) has no prompt box.
101 if (!e.isInteractive) return started
102 $.clock.every(TICK_MS, () => {
103 if (busy) return
104 busy = true
105 void (async () => {
106 try {
107 // Read each time: /clear and /resume carry on under another id.
108 const sessionId = await $.session.id()
109 sinceHello += TICK_MS
110 if (sessionId !== helloFor || sinceHello >= HELLO_EVERY_MS) {
111 await $.http.fetch(`${APP}/api/suggestion/hello`, {
112 method: 'POST', headers: HEADERS, body: JSON.stringify({ sessionId }),
113 })
114 helloFor = sessionId
115 sinceHello = 0
116 }
117 if (unsent) {
118 const sent = await $.http.fetch(`${APP}/api/suggestion`, { method: 'POST', headers: HEADERS, body: unsent })
119 if (sent.ok) unsent = undefined
120 }
121 if (!pending) return
122 pending.waited += TICK_MS
123 const { turn: forTurn, starts: forStarts, waited } = pending
124 if (ownFor === forTurn || waited > WINDOW_MS) { // Claude Code made its own, or too late
125 pending = undefined
126 return
127 }
128 if (waited < OWN_WAIT_MS) return
129 const wanted = await $.http.fetch(`${APP}/api/suggestion/wanted?session=${sessionId}`)
130 if (!wanted.ok || JSON.parse(wanted.text).wanted !== true) return // ask again next tick
131 pending = undefined
132 const reply = await $.model.fork({ prompt: PROMPT })
133 const text = reply.isAnswered ? cleanSuggestion(reply.text) : undefined
134 if (ownFor === forTurn || forTurn !== turn || forStarts !== starts) return // a newer turn
135 const shown = text ? await $.prompt.suggest({ text }) : undefined
136 // "none" tells the app this turn has nothing, so it stops waiting for one
137 const body = JSON.stringify({ sessionId, ...report(text, shown?.isShown === true, 'fork') })
138 const sent = await $.http.fetch(`${APP}/api/suggestion`, { method: 'POST', headers: HEADERS, body })
139 if (!sent.ok) unsent = body
140 } catch {
141 // Let Them Talk isn't running, or the fork failed: nothing to show.
142 } finally {
143 busy = false
144 }
145 })()
146 })
147 return started
148 })
149
150 on('prompt.suggest', async ($, e, next) => {
151 const shown = await next(e)
152 // Claude Code's own guess; one this mod (a plugin) proposed is already sent.
153 if (e.origin?.kind === 'plugin' || !e.text.trim()) return shown
154 ownFor = turn
155 let body: string | undefined
156 try {
157 const sessionId = await $.session.id()
158 body = JSON.stringify({ sessionId, ...report(e.text, shown.isShown, 'claude') })
159 const sent = await $.http.fetch(`${APP}/api/suggestion`, { method: 'POST', headers: HEADERS, body })
160 if (!sent.ok) unsent = body
161 } catch {
162 unsent = body // Let Them Talk isn't running: the suggestion shows here all the same
163 }
164 return shown
165 })
166
167 on('turn.start', async ($, e, next) => {
168 starts++
169 pending = undefined // the user moved on: a suggestion for the last turn is moot
170 if (!wanted && asked < MODEL_ASKS) {
171 asked++
172 try {
173 const reply = await $.http.fetch(`${APP}/api/mod/model?session=${await $.session.id()}`)
174 if (reply.ok) wanted = JSON.parse(reply.text).model || undefined
175 } catch {
176 // Let Them Talk isn't running: the chat's own model
177 }
178 }
179 return next(e)
180 })
181
182 on('turn.step', async function* ($, e, next) {
183 if (e.agentId || !wanted) return yield* next(e)
184 const pick = modelFor(wanted, startedOn, e.model)
185 startedOn = pick.startedOn
186 if (pick.dropped) wanted = undefined
187 return yield* next(pick.model === e.model ? e : { ...e, model: pick.model })
188 })
189
190 on('turn.complete', async ($, e, next) => {
191 const done = await next(e)
192 // The main loop's answers only: not subagents, interrupts or errors.
193 if (!e.agentId && !e.isAborted && e.reason === 'answer') pending = { turn: ++turn, starts, waited: 0 }
194 return done
195 })
196
197 // An /effort run: while the chat is idle its answer is appended at once
198 // (below); while it works, never. Then, in a background agent, for one that
199 // may have switched ultracode: /effort status, queued until it is idle.
200 on('command.run', { command: 'effort' }, async ($, e, next) => {
201 const run = ++efforts.runs
202 const ran = await next(e)
203 if (!MAY_SWITCH.test(e.args) || (await $.env.get('CLAUDE_CODE_SESSION_KIND')) !== BACKGROUND) return ran
204 $.clock.after(ANSWER_WAIT_MS, () => {
205 if (efforts.answered >= run || efforts.looking) return
206 efforts.looking = true
207 $.command.run({ command: 'effort', args: 'status' })
208 .catch(() => undefined) // the agent ended: its next prompt tells the app
209 .finally(() => { efforts.looking = false })
210 })
211 return ran
212 }).catch(($, e, next) => next(e))
213
214 // The rows a command run while the chat is idle leaves in its conversation:
215 // its name, then its answer. A subagent's are its own.
216 on('session.append', { door: 'command' }, async ($, e, next) => {
217 const kept = await next(e)
218 if (e.agentId) return kept
219 const text = e.message.content.map(block => (block.type === 'text' ? block.text : '')).join('')
220 const name = COMMAND_NAME.exec(text)
221 if (name) efforts.lastCommand = name[1]?.trim()
222 else if (COMMAND_OUT.test(text)) {
223 if (efforts.lastCommand === 'effort') efforts.answered = efforts.runs
224 efforts.lastCommand = undefined
225 }
226 return kept
227 }).catch(($, e, next) => next(e))
228}
229