DevScope in the live session: team prompt suggestions, team skills, a stuck band, outcome labels and session-to-commit links

Claude Code plugin for DevScope — real-time developer session monitoring.
This plugin hooks into Claude Code lifecycle events (session start/end, tool use, prompts, agents, etc.) and sends them to a DevScope server for real-time visualization and team insights.
One-liner install:
curl -fsSL https://raw.githubusercontent.com/DowLucas/devscope-plugin/main/install.sh | bash
The interactive installer handles plugin installation, server selection, and connection testing.
Using the cloud? Select
https://devscope.shduring setup — no server to run. Sign up at devscope.sh to get your API key.
Manual install:
# Add the marketplace (one-time)
claude plugin marketplace add DowLucas/devscope-plugin
# Install the plugin
claude plugin install devscope
Type /devscope:setup in Claude Code to interactively configure your server URL and API key.
Or manually create ~/.config/devscope/config:
mkdir -p ~/.config/devscope
cat > ~/.config/devscope/config <<EOF
DEVSCOPE_URL=https://devscope.sh
DEVSCOPE_API_KEY=your-api-key-here
EOF
| Option | URL | Description |
|---|---|---|
| Cloud (recommended) | https://devscope.sh | Hosted for you — sign up, get an API key, done |
| Self-hosted (Docker) | https://your-domain.com | Run your own instance with Docker |
| Local development | http://localhost:6767 | For contributors working on DevScope itself |
The plugin reads configuration in this priority order:
DEVSCOPE_URL, DEVSCOPE_API_KEY~/.config/devscope/config (or $XDG_CONFIG_HOME/devscope/config)http://localhost:6767jq (JSON processor)curlControl what data is sent to the server with the DEVSCOPE_PRIVACY setting in ~/.config/devscope/config:
| Mode | What's sent | Use when |
|---|---|---|
private | Tool names, file paths, durations only | Maximum privacy — no prompt or response content |
standard | Everything in private + prompt text + full tool inputs | Default — good balance for team insights |
open | Everything in standard + Claude's response text | Full session replay in the dashboard |
Set your privacy mode:
# In ~/.config/devscope/config
DEVSCOPE_PRIVACY=standard # default
DEVSCOPE_PRIVACY=private # metadata only
DEVSCOPE_PRIVACY=open # include response text
Or run /devscope:setup in Claude Code to reconfigure interactively.
Backwards compatibility: Old values
redactedandfullare automatically mapped toprivateandopenrespectively — no config changes needed.
When you send a prompt that closely matches one from an earlier session of yours, Claude gets a short note on how it went last time: tool calls, failures, and how it ended. You see a one-line notice:
DevScope: you've asked this before (2026-09-12, 2026-09-03). Claude has the notes.
It only fires on strong matches (similarity 0.9 or higher, 4+ words, from your own sessions), so most prompts get nothing. From the third separate day, Claude may suggest turning the ask into a skill. It waits at most 2 seconds and never runs in private mode.
# In ~/.config/devscope/config
DEVSCOPE_PREFLIGHT=off # turn it off (default: on)
Requires a DevScope server with semantic retrieval enabled.
When a tool call fails with an error close to one from an earlier session of yours, Claude gets the past occurrences: whether the same tool succeeded soon after, and how Claude's reply ended that time. That is often the fix. You see one line:
DevScope: you've hit this error before (2x, 1 resolved). Claude has the notes.
Paths, ids and long numbers are masked before matching, so the same failure in another file or run still matches. It asks once per distinct error per session (a retry loop costs one lookup), waits at most 2 seconds, and never runs in private mode.
# In ~/.config/devscope/config
DEVSCOPE_ERROR_RECALL=off # turn it off (default: on)
Requires a DevScope server with semantic retrieval enabled.
DevScope noticed that opening a pull request is the strongest signal that a code review comes next. After Claude opens a PR with gh pr create, the plugin shows you one line:
DevScope: PR opened. Review it next with /code-review?
The same goes for skills: DevScope learns which skill you usually run after another (from your own history: seen 3+ times, and at least a quarter of the time) and hints it after the first one runs:
DevScope: after /ship you usually run /code-review next.
By default hints are only shown to you. With DEVSCOPE_HINTS=claude, Claude sees them too and offers the step at the end of its reply ("Want me to run /code-review on it?"). Either way nothing runs until you agree. Each hint appears once per PR or skill per session and uses no network on the tool call: your skill sequences are fetched at session start (at most every 6 hours) and cached in ~/.cache/devscope/skill-chains.json.
# In ~/.config/devscope/config (environment variables take precedence)
DEVSCOPE_HINTS=on # default: shown to you only
DEVSCOPE_HINTS=claude # also tell Claude, which offers the next step
DEVSCOPE_HINTS=off # no hints
DEVSCOPE_HINT_AFTER_PR=/review-pr # suggest a different command (default: /code-review)
The first time you use a model (at session start or after /model), DevScope shows DevScope: first time on <model>. and asks Claude to offer, in one question, to check your CLAUDE.md and memory files for instructions about model selection and usage, such as rules that name an older model, and update them. Nothing is read or changed unless you say yes. Models already seen are listed in ~/.cache/devscope/models-seen; the model you are on when the plugin first runs counts as seen. A model.first_use event appears in the dashboard's live feed. DEVSCOPE_HINTS=off turns the question off.
DevScope shows each session's tokens and its API-equivalent cost: what those tokens would cost at Anthropic's API list prices, which is not what a Claude subscription plan charges you. The Stop and SessionEnd hooks sum every API call in the session transcript and its subagent transcripts, per model, and send the totals. Only token counts and model ids are sent, never transcript content.
Plugins before 0.23.0 counted only the last API call of each session, so their figures were far too low. The server replaces those with an estimate. To replace the estimate with exact numbers for sessions whose transcripts are still on your machine, run:
/devscope:backfill-usage
Claude Code deletes transcripts after cleanupPeriodDays (30 days by default, set in ~/.claude/settings.json); older sessions keep the estimate.
/devscope:search <terms> finds turns from your earlier Claude Code sessions (your prompts and Claude's replies) and links each one to the exact turn in the dashboard. It matches exact terms (identifiers, file names, error strings; "exact phrase", OR, -exclude all work) and meaning, so /devscope:search fixing the DNS outage finds the right session even if it used other words. It covers your own sessions plus teammates who share theirs; private sessions are never searched. The same search, with filters, is in the dashboard under Search.
devscope-live is a second, optional plugin in this marketplace. It is a Claude Code mod (function hooks, early access, Claude Code 2.1.291 or newer) that brings DevScope into the live session:
commit and push or /code-review high) that worked in several similar sessions (yours, and teammates who share theirs) appears as ghost text in the prompt box. Tab takes it. It stays quiet when Claude asked you something, and pauses for 30 minutes after you type over three in a row.gh. An optional DevScope-Session: trailer (off by default) marks Claude's commits and PRs./plugin install devscope-live@devscope
It reads the same DEVSCOPE_URL, DEVSCOPE_API_KEY and DEVSCOPE_PRIVACY settings. In private mode it sends no content or repository data. Each feature can be turned off in /config under the plugin's options. The devscope plugin is still what records your sessions; the mod only adds to it.
Running several sessions, or working in another window? /devscope:voice on makes DevScope tell you out loud when a session has been waiting on you for a while:
"devscope-cloud, rate limiter fix. It wants to run the database migration and is waiting for your approval."
private sessions never leave your machine and use a local template, named from the local git branch./devscope:voice model lists them, /devscope:voice model kokoro picks one for you, model default goes back to the server's choice./devscope:voice model local speaks with this computer's own voice instead (say on macOS), with no audio from the server. It uses your System voice, so to hear a Siri voice pick one in System Settings → Accessibility → Spoken Content → System voice. The DevScope server still writes the auto voice summaries; only the voice is local. model chatterbox (or another server voice) switches back. Speed with /devscope:voice speed, volume with volume in voice.json (how loud the server makes the speech) and playback_volume (how loud this computer plays it, without turning up its other sounds). private sessions, and any time the server can't be reached, use a local voice instead: Piper if you ran /devscope:voice setup (Linux; pipx install piper-tts on macOS), otherwise the system voice (say, spd-say, espeak).Other commands: off, mute 1h, unmute, test, status, finished on (also announce finished turns after 2 min), speed slow|normal|fast (1.0×, 1.2× or 1.5×, for every voice; or any number from 0.5 to 2, e.g. speed 1.35), stop (end speech in progress). Settings live in ~/.config/devscope/voice.json.
/devscope:voice verbosity short|normal|long sets how detailed spoken responses are, for explanations and auto voice together; name one to set only that (verbosity auto short, verbosity explain long). Plain verbosity shows both.
short | normal (default) | long | |
|---|---|---|---|
| Auto voice | one sentence, the outcome | two or three sentences | four to six: what changed, why, what's next |
| Explain | about 40 seconds | about a minute and a half | about three minutes |
/devscope:voice explain --short <topic> (or --long) overrides it for one explanation.
DevScope only talks to someone at the computer. While the screen is locked:
| What happens | |
|---|---|
| Announcements ("a session is waiting") | held, then said a few seconds after you unlock if the session still waits; no reminder is used up |
| Auto voice | skipped; the reply is on screen when you are back |
| An explanation | stops after the piece that is playing |
It is detected locally, with nothing to install: on macOS from ioreg (CGSSessionScreenIsLocked) and the screensaver, on Linux from logind's LockedHint for your graphical session (GNOME, KDE and most lockers set it). Where it cannot tell (an SSH session, a server, a locker that does not set LockedHint, such as a bare i3lock) speech plays as before. An announcement held for more than 30 minutes is dropped, as any other that old is. /devscope:voice when-locked play turns this off; when-locked quiet (the default) turns it back on, and status shows whether the screen reads as locked.
Long speech is voiced in as few pieces as possible: text that fits one request (440 characters, so every normal summary) is one piece, and longer text is split only between sentences, so there is no pause mid-sentence.
Auto voice is a short spoken summary whenever Claude finishes a reply: the outcome first, then anything you need to decide, in two or three sentences. It works with the announcer on or off, and it is per session:
/devscope:voice auto turns it on or off for the session you type it in (auto on, auto off; plain auto toggles). It lasts as long as that Claude Code window, /clear included./devscope:voice auto-default on|off is the setting for every session where you have not used auto (off unless you turn it on)./devscope:voice status shows both. The reply is sent to your DevScope server to summarize and is not stored; in private mode you only hear which project finished, voiced locally.
/devscope:voice explain [topic] talks a topic through like a colleague at a whiteboard: the question, a concrete scenario with real names and values, the options weighed out loud, where it leans, and a question back to you. A short written card stays in the chat. It speaks once, only when you run it; turn on auto voice to hear later replies too. With no topic it explains whatever you were just working on.
/devscope:voice explain why reminders sometimes come twice
| Event | Data Sent |
|---|---|
| Session start/end | Session duration, permission mode |
| Tool use | Tool name, duration, success/failure |
| Prompt submit | Prompt length |
| Subagent start/stop | Agent type, task description (not in private mode) and model |
| Response complete | Tools used, response length |
| Task completed | Task details |
| And more... | Notifications, compaction, config changes |
All tracking hooks are async and non-blocking, so they won't slow down your Claude Code sessions. The one exception is the next-step hint, which has to be synchronous to show its message; it only reacts to gh pr create and returns in a few milliseconds otherwise.
Works on Linux and macOS. Cross-platform compatibility is handled automatically for:
sha256sum / shasum / openssl)/proc/sys/kernel/random/uuid / uuidgen)Missing git identity — the plugin derives your developer ID from git config user.email. If it's not set, events can't be attributed:
git config --global user.email "you@example.com"
git config --global user.name "Your Name"
The installer checks for this, but if you skipped the warning, set them now.
Config not loaded — verify your config:
cat ~/.config/devscope/config
# Should show DEVSCOPE_URL and DEVSCOPE_API_KEY
Server unreachable — test the connection:
curl -sf "$(grep DEVSCOPE_URL ~/.config/devscope/config | cut -d= -f2)/api/health"
Invalid API key — generate a new key from Dashboard > Settings > API Keys.
Check that the plugin is installed and enabled:
claude plugin list
If missing, reinstall:
claude plugin marketplace add DowLucas/devscope-plugin
claude plugin install devscope
Claude Code caches plugins by version. After updating:
claude plugin update devscope
# Restart Claude Code for changes to take effect
For plugin-specific changes, open a PR here. For server/dashboard changes, see the main DevScope repo.
See CONTRIBUTING.md for guidelines.
hooks/register.tsx 475 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Band, StuckNudge, VoiceView } from '../types'
5import { UNREADABLE_CONFIG, parseConfig, readOptions, resolveConfig } from './config'
6import type { Config, Options } from './config'
7import { implicitLabel, shouldAsk } from './labels'
8import type { Label } from './labels'
9import { STEP_BACK_PROMPT, afterPrompt, basename, fitsSuggestion, nextPromptsBody, shouldSuggestAfter } from './suggestions'
10import type { Suggestion } from './suggestions'
11import { USE_IT, matchSkill, skillContext, skillLabel } from './teamSkills'
12import type { TeamSkill } from './teamSkills'
13import { isPrUrl, linkFromBash, parseGhPr, withTrailer, withoutCredentials } from './vcs'
14import { barCells, fraction, isOwnSpeech, isStale, otherSpeechLabel, parseProgress, runs, voiceLabel } from './voiceBar'
15
16const band = atom({ plugin: 'devscope-live', key: 'band' } as const, null as Band)
17const voice = atom({ plugin: 'devscope-live', key: 'voice' } as const, null as VoiceView)
18
19/** `$.http.fetch` has no timeout of its own; past this a request is given up on. */
20const REQUEST_TIMEOUT_MS = 5000
21/** Better Auth's per-key window resets once the key has been idle a full second. */
22const RATE_LIMIT_RETRY_MS = 1500
23/** The Bash plugin posts the failure event in the background; give it time to land. */
24const NUDGE_DELAY_MS = 2500
25/** A team suggestion replaces the engine's own guess for this long after it arrives. */
26const SUGGESTION_FRESH_MS = 60_000
27const SKILLS_CACHE_MS = 6 * 60 * 60 * 1000
28const PR_CHECK_MS = 6 * 60 * 60 * 1000
29/** How often to look for speech while there is none, and to redraw while there is. */
30const VOICE_IDLE_MS = 1000
31const VOICE_FRAME_MS = 120
32
33const iso = (ms: number) => new Date(ms).toISOString()
34
35// ---- DevScope settings and backend (fail open: any problem is `undefined`) ----
36
37let configPromise: Promise<Config> | undefined
38
39async function readConfig($: EngineInterface): Promise<Config> {
40 const env = {
41 url: await $.env.get('DEVSCOPE_URL'),
42 apiKey: await $.env.get('DEVSCOPE_API_KEY'),
43 privacy: await $.env.get('DEVSCOPE_PRIVACY'),
44 }
45 const configHome =
46 (await $.env.get('XDG_CONFIG_HOME')) || `${(await $.env.get('HOME')) ?? ''}/.config`
47 const path = `${configHome}/devscope/config`
48 // No file means defaults. A file that exists but can't be read might say
49 // `private`, so it is taken to (the environment still wins, as in bash).
50 const exists = await $.fs.exists(path).catch(() => true)
51 const file = exists ? await $.fs.read(path).then(parseConfig, () => UNREADABLE_CONFIG) : {}
52 return resolveConfig(env, file)
53}
54
55function config($: EngineInterface): Promise<Config> {
56 return (configPromise ??= readConfig($))
57}
58async function isPrivate($: EngineInterface): Promise<boolean> {
59 return (await config($)).privacy === 'private'
60}
61
62async function request<T>($: EngineInterface, method: 'GET' | 'POST', path: string, body?: unknown) {
63 try {
64 const { url, apiKey } = await config($)
65 const headers: Record<string, string> = { 'x-requested-with': 'devscope-live' }
66 if (apiKey) headers['x-api-key'] = apiKey
67 if (body !== undefined) headers['content-type'] = 'application/json'
68 const send = () =>
69 Promise.race([
70 $.http.fetch(`${url}${path}`, {
71 method,
72 headers,
73 body: body === undefined ? undefined : JSON.stringify(body),
74 }),
75 $.clock.sleep(REQUEST_TIMEOUT_MS).then(() => undefined),
76 ])
77 let response = await send()
78 // The API key's rate limit is shared with the Bash plugin's events: a write
79 // waits out the window and tries once more; a read just goes without.
80 if (response?.status === 429 && method === 'POST') {
81 await $.clock.sleep(RATE_LIMIT_RETRY_MS)
82 response = await send()
83 }
84 return response?.ok ? (JSON.parse(response.text) as T) : undefined
85 } catch {
86 return undefined
87 }
88}
89
90// ---- What this module knows of the session (a reload starts it over) ----
91
92let options: Options = readOptions({})
93
94const turn = {
95 runningId: undefined as string | undefined,
96 startedAt: undefined as string | undefined,
97 lastPrompt: undefined as string | undefined,
98 toolCalls: 0,
99}
100let skills: TeamSkill[] = []
101const offered = new Set<string>()
102let lastAskAt = Number.NEGATIVE_INFINITY
103let suggestion: { text: string; at: number } | undefined
104/** Suggestions typed over in a row, and until when suggestions are paused. */
105let suggestState = { ignored: 0, pausedUntil: Number.NEGATIVE_INFINITY }
106let nudgeCheck: Timer | undefined
107let voiceIdle: Timer | undefined
108let voiceFrames: Timer | undefined
109
110// ---- #3 team skills ----
111
112async function loadTeamSkills($: EngineInterface): Promise<TeamSkill[]> {
113 const now = await $.clock.now()
114 const cached = (await $.store.get('teamSkills')) as { at: number; skills: TeamSkill[] } | undefined
115 if (cached && now - cached.at < SKILLS_CACHE_MS) return cached.skills
116 const fresh = await request<{ skills: TeamSkill[] }>($, 'GET', '/api/live/team-skills')
117 if (!Array.isArray(fresh?.skills)) return cached?.skills ?? []
118 await $.store.set('teamSkills', { at: now, skills: fresh.skills })
119 return fresh.skills
120}
121
122/** True only when the person picked "Use it"; dismissed or headless is a no. */
123async function offerSkill($: EngineInterface, skill: TeamSkill): Promise<boolean> {
124 try {
125 return (await $.ui.ask(`Team skill "${skillLabel(skill)}" covers this. Use it?`, [USE_IT, 'Not now'])) === USE_IT
126 } catch {
127 return false
128 }
129}
130
131// ---- #2 team prompts ----
132
133async function proposeNextPrompt($: EngineInterface, after: string | undefined) {
134 const body = nextPromptsBody({
135 sessionId: await $.session.id(),
136 project: basename(await $.session.cwd()),
137 after,
138 })
139 const response = await request<{ suggestions: Suggestion[] }>($, 'POST', '/api/live/next-prompts', body)
140 const text = Array.isArray(response?.suggestions) ? response.suggestions[0]?.text?.trim() : undefined
141 if (!text || !fitsSuggestion(text)) return
142 suggestion = { text, at: await $.clock.now() }
143 await $.prompt.suggest({ text }).catch(() => undefined)
144}
145
146// ---- #6 stuck band ----
147
148async function showPendingNudge($: EngineInterface) {
149 const sessionId = encodeURIComponent(await $.session.id())
150 const response = await request<{ nudge: StuckNudge | null }>($, 'GET', `/api/live/nudge?session_id=${sessionId}`)
151 const nudge = response?.nudge
152 if (nudge) await update($, band, (): Band => ({ kind: 'stuck', nudge }))
153}
154
155async function abortRunningTurn($: EngineInterface) {
156 if (turn.runningId) await $.turn.abort({ turnId: turn.runningId }).catch(() => undefined)
157}
158
159// ---- #8 outcome labels ----
160
161async function sendLabel($: EngineInterface, turnStartedAt: string, label: Label, source: 'explicit' | 'implicit') {
162 await request($, 'POST', '/api/live/labels', {
163 session_id: await $.session.id(),
164 turn_started_at: turnStartedAt,
165 label,
166 source,
167 })
168}
169
170// ---- #9 session ↔ commit links ----
171
172async function repoRemote($: EngineInterface): Promise<string | undefined> {
173 const remote = (await $.session.repo())?.remote
174 return remote ? withoutCredentials(remote) : undefined
175}
176
177/**
178 * Settles the state of this repository's open PRs that past sessions made,
179 * with the person's own `gh`, at most every 6 hours per repository. Silent
180 * on any failure (no `gh`, not logged in, no network).
181 */
182async function resolvePrs($: EngineInterface, remote: string) {
183 const now = await $.clock.now()
184 const checked = ((await $.store.get('prCheckAt')) ?? {}) as Record<string, number>
185 if (now - (checked[remote] ?? Number.NEGATIVE_INFINITY) < PR_CHECK_MS) return
186 await $.store.set('prCheckAt', { ...checked, [remote]: now })
187
188 const open = await request<{ prs: { ref: string }[] }>(
189 $,
190 'GET',
191 `/api/live/vcs/open-prs?repo_remote=${encodeURIComponent(remote)}`,
192 )
193 for (const { ref } of Array.isArray(open?.prs) ? open.prs : []) {
194 if (typeof ref !== 'string' || !isPrUrl(ref)) continue
195 const view = await $.process
196 .run(['gh', 'pr', 'view', ref, '--json', 'state,mergedAt,closedAt'], { timeoutMs: 15_000 })
197 .catch(() => undefined)
198 if (!view) return
199 const status = view.exitCode === 0 ? parseGhPr(view.stdout) : undefined
200 if (status) await request($, 'POST', '/api/live/vcs/status', { ref, ...status })
201 }
202}
203
204// ---- Voice progress bar (the Bash plugin's speaker writes progress.json) ----
205
206async function voiceProgressPath($: EngineInterface): Promise<string> {
207 return `${(await $.env.get('HOME')) ?? ''}/.cache/devscope/voice/progress.json`
208}
209
210/** Reads the speaker's progress into the bar; false when nothing is speaking. */
211async function pollVoice($: EngineInterface): Promise<boolean> {
212 const text = await $.fs.read(await voiceProgressPath($)).catch(() => undefined)
213 const progress = typeof text === 'string' ? parseProgress(text) : undefined
214 if (!progress || isStale(progress, await $.clock.now())) {
215 if ((await read($, voice)) !== null) await update($, voice, () => null)
216 return false
217 }
218 await update($, voice, (current): VoiceView => ({ progress, frame: (current?.frame ?? 0) + 1 }))
219 return true
220}
221
222/** Looks once a second; while something speaks, redraws a few times a second. */
223function watchVoice($: EngineInterface) {
224 voiceIdle?.cancel()
225 voiceIdle = $.clock.every(VOICE_IDLE_MS, () => {
226 if (voiceFrames) return
227 void pollVoice($).then(active => {
228 if (!active || voiceFrames) return
229 voiceFrames = $.clock.every(VOICE_FRAME_MS, () => {
230 void pollVoice($).then(still => {
231 if (still) return
232 voiceFrames?.cancel()
233 voiceFrames = undefined
234 })
235 })
236 })
237 })
238}
239
240function stopWatchingVoice() {
241 voiceIdle?.cancel()
242 voiceFrames?.cancel()
243 voiceIdle = voiceFrames = undefined
244}
245
246/** Ends the speaker's process group (the player with it), as /devscope:voice stop does. */
247async function stopSpeaking($: EngineInterface, pid: number) {
248 const run = (argv: string[]) => $.process.run(argv, { timeoutMs: 2000 }).catch(() => undefined)
249 const group = await run(['kill', '-TERM', '--', `-${pid}`])
250 if (group?.exitCode !== 0) await run(['kill', '-TERM', String(pid)])
251 await update($, voice, () => null)
252}
253
254export const register: Register = (on, pluginOptions) => {
255 options = readOptions(pluginOptions)
256
257 // ---- Hooks ----
258
259 on('session.start', async ($, e, next) => {
260 const started = await next(e)
261 if (options.voiceProgress && e.isInteractive) watchVoice($)
262 // Network work never holds up the first prompt.
263 $.clock.after(0, () => {
264 void (async () => {
265 if (options.teamSkills) skills = await loadTeamSkills($)
266 if (await isPrivate($)) return
267 const remote = await repoRemote($)
268 if (options.commitLinks && remote) await resolvePrs($, remote)
269 if (options.nextPrompts && e.isInteractive && (await $.clock.now()) >= suggestState.pausedUntil) {
270 await proposeNextPrompt($, undefined)
271 }
272 })()
273 })
274 return started
275 })
276
277 on('session.end', async ($, e, next) => {
278 // A /clear ends the conversation without a new session.start.
279 Object.assign(turn, { runningId: undefined, startedAt: undefined, lastPrompt: undefined, toolCalls: 0 })
280 offered.clear()
281 suggestion = undefined
282 await update($, band, () => null)
283 stopWatchingVoice()
284 await update($, voice, () => null)
285 return next(e)
286 })
287
288 on('prompt.submit', async ($, e, next) => {
289 if (e.origin.kind !== 'composer') return next(e)
290
291 // The reply labels the turn it answers.
292 if (options.outcomeLabels && turn.startedAt && !(await isPrivate($))) {
293 const label = implicitLabel(e.text)
294 if (label) void sendLabel($, turn.startedAt, label, 'implicit')
295 }
296 if (suggestion) suggestState = afterPrompt(suggestState, e.text, suggestion.text, await $.clock.now())
297 Object.assign(turn, { startedAt: iso(await $.clock.now()), lastPrompt: e.text, toolCalls: 0 })
298 suggestion = undefined
299 await update($, band, current => (current?.kind === 'label' ? null : current))
300
301 const skill = options.teamSkills ? matchSkill(e.text, skills) : undefined
302 if (skill && !offered.has(skill.id)) {
303 offered.add(skill.id)
304 if (await offerSkill($, skill)) {
305 return next({ ...e, context: [...(e.context ?? []), skillContext(skill)] })
306 }
307 }
308 return next(e)
309 }).catch(($, e, next) => next(e)) // fail open; replays a settled `next`, so nothing runs twice
310
311 on('turn.start', ($, e, next) => {
312 turn.runningId = e.turnId
313 return next(e)
314 })
315
316 on('tool.call', async ($, e, next) => {
317 const ran = await next(e)
318 if (ran.deny !== undefined) return ran
319 turn.toolCalls += 1
320
321 // One check, after the last of a run of failures: the rule that raises
322 // the nudge trips on a later failure than the first.
323 if (ran.isError === true && options.stuckBand) {
324 nudgeCheck?.cancel()
325 nudgeCheck = $.clock.after(NUDGE_DELAY_MS, () => {
326 nudgeCheck = undefined
327 void showPendingNudge($)
328 })
329 }
330
331 if (e.tool === 'Bash' && ran.isError !== true && options.commitLinks) {
332 const stdout = (ran.result as { stdout?: string } | undefined)?.stdout ?? ''
333 const link = linkFromBash(e.command, stdout)
334 if (link) {
335 void (async () => {
336 if (await isPrivate($)) return
337 await request($, 'POST', '/api/live/vcs', {
338 session_id: await $.session.id(),
339 ...link,
340 repo_remote: await repoRemote($),
341 })
342 })()
343 }
344 }
345 return ran
346 }).catch(($, e, next) => next(e)) // fail open; replays a settled `next`, so nothing runs twice
347
348 on('turn.complete', async ($, e, next) => {
349 const done = await next(e)
350 if (e.agentId !== undefined) return done
351 turn.runningId = undefined
352 if (e.isAborted || e.reason !== 'answer') return done
353
354 const { startedAt, lastPrompt, toolCalls } = turn
355 $.clock.after(0, () => {
356 void (async () => {
357 if (await isPrivate($)) return
358 const now = await $.clock.now()
359 if (options.outcomeLabels && startedAt && shouldAsk({ durationMs: e.durationMs, toolCalls }, lastAskAt, now)) {
360 lastAskAt = now
361 const ask: Band = { kind: 'label', turnStartedAt: startedAt }
362 await update($, band, current => current ?? ask)
363 }
364 if (
365 options.nextPrompts &&
366 lastPrompt &&
367 shouldSuggestAfter({ answer: e.answer, toolCalls }) &&
368 now >= suggestState.pausedUntil
369 ) {
370 await proposeNextPrompt($, lastPrompt)
371 }
372 })()
373 })
374 return done
375 })
376
377 // The team's proven next step wins over the engine's own guess.
378 on('prompt.suggest', async ($, e, next) => {
379 if (e.origin.kind !== 'suggestion' || !suggestion) return next(e)
380 if ((await $.clock.now()) - suggestion.at > SUGGESTION_FRESH_MS) return next(e)
381 return next({ ...e, text: suggestion.text })
382 })
383
384 on('attribution.text', async ($, e, next) => {
385 const result = await next(e)
386 if (!options.commitTrailer || (e.kind !== 'commit' && e.kind !== 'pr')) return result
387 if (await isPrivate($)) return result
388 return { text: withTrailer(result.text, await $.session.id()) }
389 })
390
391 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
392 const current = await read($, band)
393 const speaking = await read($, voice)
394 if ((current === null && speaking === null) || e.props.hasSurvey) return next(e)
395 const { Box, Button, Text } = $.ui.resolve(e)
396 const clear = () => update($, band, () => null)
397
398 // The bar (and its Stop) belongs to the window whose session is speaking;
399 // every other window gets one dimmed line naming the session.
400 const own = speaking ? isOwnSpeech(speaking.progress, await $.session.id()) : false
401 const voiceRow = speaking && !own ? (
402 <Box key="voice">
403 <Text dimColor>{otherSpeechLabel(speaking.progress)}</Text>
404 </Box>
405 ) : speaking ? (
406 <Box key="voice" gap={1}>
407 <Box>
408 {runs(barCells(fraction(speaking.progress, await $.clock.now()), speaking.frame)).map((run, i) => (
409 <Text key={`voice-bar-${i}`} color={run.color}>
410 {run.char}
411 </Text>
412 ))}
413 </Box>
414 <Text dimColor>{voiceLabel(speaking.progress)}</Text>
415 <Button key="voice-stop" label="Stop" dimColor onPress={() => stopSpeaking($, speaking.progress.pid)} />
416 </Box>
417 ) : null
418
419 let bandRow = null
420 if (current?.kind === 'stuck') {
421 bandRow = (
422 <Box key="band" flexDirection="column">
423 <Text color="warning">DevScope: {current.nudge.message}</Text>
424 <Box gap={1}>
425 <Button
426 key="step-back"
427 label="Step back"
428 variant="primary"
429 onPress={async () => {
430 await clear()
431 await abortRunningTurn($)
432 await $.prompt.submit({ text: STEP_BACK_PROMPT }).catch(() => undefined)
433 }}
434 />
435 <Button
436 key="stop"
437 label="Stop"
438 onPress={async () => {
439 await clear()
440 await abortRunningTurn($)
441 }}
442 />
443 <Button key="keep-going" label="Keep going" onPress={clear} />
444 </Box>
445 </Box>
446 )
447 } else if (current?.kind === 'label') {
448 const answer = (label: Label) => async () => {
449 await clear()
450 await sendLabel($, current.turnStartedAt, label, 'explicit')
451 $.ui.toast('DevScope: thanks, noted.')
452 }
453 bandRow = (
454 <Box key="band" gap={1}>
455 <Text dimColor>DevScope: did that work?</Text>
456 <Button key="label-up" label="👍 Yes" onPress={answer('up')} />
457 <Button key="label-partial" label="Partly" onPress={answer('partial')} />
458 <Button key="label-down" label="👎 No" onPress={answer('down')} />
459 <Button key="label-dismiss" label="Skip" dimColor onPress={clear} />
460 </Box>
461 )
462 }
463
464 if (voiceRow && bandRow) {
465 return (
466 <Box flexDirection="column">
467 {voiceRow}
468 {bandRow}
469 </Box>
470 )
471 }
472 return voiceRow ?? bandRow ?? next(e)
473 })
474}
475hooks/config.ts 81 lines1import type { PluginOptions } from 'claude-code'
2
3export type Privacy = 'standard' | 'private' | 'open'
4
5export type Config = {
6 url: string
7 apiKey: string | undefined
8 privacy: Privacy
9}
10
11/** The mod's own toggles (plugin.json `userConfig`). */
12export type Options = {
13 nextPrompts: boolean
14 teamSkills: boolean
15 stuckBand: boolean
16 outcomeLabels: boolean
17 commitLinks: boolean
18 commitTrailer: boolean
19 voiceProgress: boolean
20}
21
22export function readOptions(options: PluginOptions): Options {
23 const flag = (name: keyof Options, fallback: boolean) =>
24 typeof options[name] === 'boolean' ? (options[name] as boolean) : fallback
25 return {
26 nextPrompts: flag('nextPrompts', true),
27 teamSkills: flag('teamSkills', true),
28 stuckBand: flag('stuckBand', true),
29 outcomeLabels: flag('outcomeLabels', true),
30 commitLinks: flag('commitLinks', true),
31 commitTrailer: flag('commitTrailer', false),
32 voiceProgress: flag('voiceProgress', true),
33 }
34}
35
36/**
37 * The Bash plugin's settings with its precedence (scripts/_helpers.sh): the
38 * environment, then the config file, then defaults.
39 */
40export function resolveConfig(
41 env: { url?: string; apiKey?: string; privacy?: string },
42 file: Record<string, string>,
43): Config {
44 const url = env.url || file.DEVSCOPE_URL || 'http://localhost:6767'
45 const privacy = env.privacy || file.DEVSCOPE_PRIVACY
46 return {
47 url: url.replace(/\/+$/, ''),
48 apiKey: env.apiKey || file.DEVSCOPE_API_KEY || undefined,
49 privacy: privacy === 'private' || privacy === 'open' ? privacy : 'standard',
50 }
51}
52
53/** What a config file that exists but can't be read is taken to say. */
54export const UNREADABLE_CONFIG: Record<string, string> = { DEVSCOPE_PRIVACY: 'private' }
55
56/**
57 * `KEY=value` lines read as _helpers.sh reads them, so both plugins agree on
58 * the privacy mode: `#` lines skipped, spaces dropped from the key, one
59 * leading and one trailing `"` then `'` stripped from the value, and the
60 * first value of a key wins. Trimming the value too only ever makes a
61 * `private` setting match where the shell's would not.
62 */
63export function parseConfig(text: string): Record<string, string> {
64 const values: Record<string, string> = {}
65 for (const line of text.split('\n')) {
66 if (line.startsWith('#')) continue
67 const at = line.indexOf('=')
68 if (at < 0) continue
69 const key = line.slice(0, at).replace(/ /g, '')
70 const value = line
71 .slice(at + 1)
72 .replace(/^"/, '')
73 .replace(/"$/, '')
74 .replace(/^'/, '')
75 .replace(/'$/, '')
76 .trim()
77 if (key && !(key in values)) values[key] = value
78 }
79 return values
80}
81hooks/labels.ts 28 lines1export type Label = 'up' | 'partial' | 'down'
2
3/** A reply that says the last turn did not do what was asked. */
4const DOWN =
5 /^(no\b|nope\b|wrong\b|that'?s (not|wrong)|(it|that|this) (is|isn'?t) (not )?(right|working)|still (fail|broken|not|doesn'?t|error)|((it|that|this) )?(doesn'?t|didn'?t|does not|did not) work|((it|that) )?(still )?(fails|failed|errors|breaks|broke)\b|revert\b|undo (that|this|it)\b)/i
6/** A reply that says it did. */
7const UP = /^(thanks|thank you|thx|perfect|great|nice|awesome|it works|that works|works\b|lgtm|looks good)/i
8
9/** The label a reply implies for the turn it answers, if it clearly implies one. */
10export function implicitLabel(reply: string): Label | undefined {
11 const text = reply.trim()
12 if (DOWN.test(text)) return 'down'
13 if (UP.test(text)) return 'up'
14 return undefined
15}
16
17/** Ask "did that work?" only after a long or busy turn, at most every 30 minutes. */
18export const ASK = { minDurationMs: 120_000, minToolCalls: 15, gapMs: 30 * 60_000 }
19
20export function shouldAsk(
21 turn: { durationMs: number; toolCalls: number },
22 lastAskAt: number,
23 now: number,
24): boolean {
25 const isBig = turn.durationMs >= ASK.minDurationMs || turn.toolCalls >= ASK.minToolCalls
26 return isBig && now - lastAskAt >= ASK.gapMs
27}
28hooks/suggestions.ts 68 lines1/** One proposal from `POST /api/live/next-prompts`. */
2export type Suggestion = { text: string; project: string }
3
4/**
5 * The request for the next prompt that worked after a similar one (`after`),
6 * or, with no `after` yet, a prompt that opened a successful session in
7 * `project`.
8 */
9export function nextPromptsBody(input: { sessionId: string; project: string; after?: string }) {
10 return {
11 session_id: input.sessionId,
12 project: input.project,
13 ...(input.after ? { after: input.after.slice(0, 4000) } : {}),
14 limit: 1,
15 }
16}
17
18/** Thresholds for team prompt suggestions: few, short, and only when they fit. */
19export const SUGGEST = {
20 /** Shown text is a few words; anything longer is dropped, whatever the server sent. */
21 maxWords: 5,
22 maxChars: 60,
23 /** After this many suggestions in a row are typed over, stop for a while. */
24 ignoreLimit: 3,
25 pauseMs: 30 * 60 * 1000,
26} as const
27
28const fold = (text: string) => text.toLowerCase().replace(/\s+/g, ' ').trim()
29
30/** Short enough to read at a glance in the prompt box, and on one line. */
31export function fitsSuggestion(text: string): boolean {
32 const t = text.trim()
33 return t !== '' && !t.includes('\n') && t.length <= SUGGEST.maxChars && t.split(/\s+/).length <= SUGGEST.maxWords
34}
35
36/**
37 * Whether a finished turn is a moment for a suggestion: Claude did some work
38 * (a pure chat answer leaves the next step open), and did not end by asking
39 * something: then the person's answer is the next prompt, and only they know it.
40 */
41export function shouldSuggestAfter(turn: { answer: string; toolCalls: number }): boolean {
42 if (turn.toolCalls < 1) return false
43 const lastLine = turn.answer.trim().split('\n').filter(l => l.trim() !== '').at(-1) ?? ''
44 return !/\?\W*$/.test(lastLine.trim())
45}
46
47/**
48 * The next state after a prompt is sent while a suggestion was showing: taking
49 * it resets the count, typing something else counts as ignoring it, and the
50 * third ignore in a row pauses suggestions for `SUGGEST.pauseMs`.
51 */
52export function afterPrompt(
53 state: { ignored: number; pausedUntil: number },
54 sent: string,
55 suggested: string,
56 now: number,
57): { ignored: number; pausedUntil: number } {
58 if (fold(sent) === fold(suggested)) return { ignored: 0, pausedUntil: state.pausedUntil }
59 const ignored = state.ignored + 1
60 return ignored >= SUGGEST.ignoreLimit ? { ignored: 0, pausedUntil: now + SUGGEST.pauseMs } : { ignored, pausedUntil: state.pausedUntil }
61}
62
63/** What "Step back" asks of Claude after interrupting the turn. */
64export const STEP_BACK_PROMPT =
65 'Stop retrying for a moment. Summarize what you have tried, why it keeps failing, and propose a different approach before running anything else.'
66
67export const basename = (path: string) => path.replace(/\/+$/, '').split('/').pop() || path
68hooks/teamSkills.ts 44 lines1export type TeamSkill = {
2 id: string
3 name: string
4 description: string
5 triggerPhrases: string[]
6 content: string
7}
8
9/** A shorter phrase ("test", "fix it") matches far more prompts than it means. */
10const MIN_PHRASE_CHARS = 8
11const MAX_CONTENT_CHARS = 20_000
12export const USE_IT = 'Use it'
13
14const normalize = (text: string) =>
15 ` ${text.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim()} `
16
17/** The skill whose longest trigger phrase appears in the prompt as whole words. */
18export function matchSkill(prompt: string, skills: readonly TeamSkill[]): TeamSkill | undefined {
19 const text = normalize(prompt)
20 let best: { skill: TeamSkill; length: number } | undefined
21 for (const skill of skills) {
22 for (const phrase of skill.triggerPhrases) {
23 const needle = normalize(phrase)
24 const length = needle.trim().length
25 if (length >= MIN_PHRASE_CHARS && length > (best?.length ?? 0) && text.includes(needle)) {
26 best = { skill, length }
27 }
28 }
29 }
30 return best?.skill
31}
32
33/** A skill's name as one short line, for the question and the context header. */
34export const skillLabel = (skill: TeamSkill) => skill.name.replace(/\s+/g, ' ').trim().slice(0, 100)
35
36/** What the model reads beside the prompt once the person chose the skill. */
37export function skillContext(skill: TeamSkill): string {
38 return [
39 `DevScope: the user chose to apply their team's skill "${skillLabel(skill)}" to this request. Follow it:`,
40 '',
41 skill.content.slice(0, MAX_CONTENT_CHARS),
42 ].join('\n')
43}
44hooks/vcs.ts 59 lines1export type VcsLink = { kind: 'commit' | 'pr'; ref: string }
2
3const COMMIT = /\bgit\s+(-C\s+\S+\s+)?commit\b/
4const PR_CREATE = /\bgh\s+pr\s+create\b/
5/** `git commit` prints `[branch 1a2b3c4] subject` (or `[main (root-commit) 1a2b3c4]`). */
6const COMMIT_SHA = /^\[[^\]\n]*?\b([0-9a-f]{7,40})\]/m
7const PR_URL = /https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/pull\/\d+/
8const EXACT_PR_URL = new RegExp(`^${PR_URL.source}$`)
9
10/**
11 * Whether `ref` is exactly a GitHub PR URL. Refs come back from the backend
12 * and become `gh` arguments, so nothing else (an option, a path) may pass.
13 */
14export const isPrUrl = (ref: string) => EXACT_PR_URL.test(ref)
15
16/**
17 * A remote URL without embedded credentials: everything up to the last `@`
18 * of a `scheme://` URL's authority goes, so a password holding `@` or `:`
19 * can't survive in part. An scp-style `git@host:path` keeps its user.
20 */
21export const withoutCredentials = (remote: string) => remote.replace(/^([a-z][a-z0-9+.-]*:\/\/)[^/]*@/i, '$1')
22
23/** The commit or PR a successful Bash call made, read from its output. */
24export function linkFromBash(command: string, stdout: string): VcsLink | undefined {
25 if (COMMIT.test(command)) {
26 const sha = COMMIT_SHA.exec(stdout)?.[1]
27 return sha ? { kind: 'commit', ref: sha } : undefined
28 }
29 if (PR_CREATE.test(command)) {
30 const url = PR_URL.exec(stdout)?.[0]
31 return url ? { kind: 'pr', ref: url } : undefined
32 }
33 return undefined
34}
35
36export function withTrailer(text: string, sessionId: string): string {
37 const line = `DevScope-Session: ${sessionId}`
38 if (text.includes(line)) return text
39 return text ? `${text}\n${line}` : line
40}
41
42export type PrStatus = { state: 'open' | 'merged' | 'closed'; merged_at?: string; closed_at?: string }
43
44/** `gh pr view --json state,mergedAt,closedAt` output, as the backend takes it. */
45export function parseGhPr(json: string): PrStatus | undefined {
46 try {
47 const pr = JSON.parse(json) as { state?: string; mergedAt?: string | null; closedAt?: string | null }
48 const state = pr.state?.toLowerCase()
49 if (state !== 'open' && state !== 'merged' && state !== 'closed') return undefined
50 return {
51 state,
52 ...(pr.mergedAt ? { merged_at: pr.mergedAt } : {}),
53 ...(pr.closedAt ? { closed_at: pr.closedAt } : {}),
54 }
55 } catch {
56 return undefined
57 }
58}
59hooks/voiceBar.ts 123 lines1import type { VoiceProgress } from '../types'
2
3/** Cells in the bar; each holds six braille dots, so 24 cells are 144 steps. */
4export const BAR_CELLS = 24
5/** 6-dot braille by how many dots are lit, left column first: ⠀ ⠁ ⠃ ⠇ ⠏ ⠟ ⠿ */
6export const LEVELS = ['⠀', '⠁', '⠃', '⠇', '⠏', '⠟', '⠿'] as const
7/** The unlit track: the middle row of dots. */
8export const TRACK = '⠒'
9export const TRACK_COLOR = '#4b5563'
10/** Violet to pink to amber, left to right. */
11const STOPS: [number, number, number][] = [
12 [0x7c, 0x3a, 0xed],
13 [0xdb, 0x27, 0x77],
14 [0xf5, 0x9e, 0x0b],
15]
16/** The sweep shown while there is nothing to measure yet. */
17const COMET = [1, 3, 6, 6, 3, 1]
18
19export type Cell = { char: string; color: string }
20
21/** The progress file's contents, or undefined for anything malformed. */
22export function parseProgress(text: string): VoiceProgress | undefined {
23 try {
24 const p = JSON.parse(text) as Partial<VoiceProgress>
25 const num = (v: unknown) => typeof v === 'number' && Number.isFinite(v) && v >= 0
26 if (p.kind !== 'explain' && p.kind !== 'reply') return undefined
27 if (p.phase !== 'summarizing' && p.phase !== 'voicing' && p.phase !== 'speaking') return undefined
28 if (!num(p.piece) || !num(p.pieces) || !num(p.pieceMs) || !num(p.at)) return undefined
29 if (!Number.isInteger(p.pid) || (p.pid as number) <= 1) return undefined
30 return {
31 kind: p.kind,
32 project: typeof p.project === 'string' ? p.project.slice(0, 60) : '',
33 phase: p.phase,
34 piece: p.piece as number,
35 pieces: p.pieces as number,
36 pieceMs: p.pieceMs as number,
37 at: p.at as number,
38 pid: p.pid as number,
39 sessionId: typeof p.sessionId === 'string' ? p.sessionId.slice(0, 200) : '',
40 }
41 } catch {
42 return undefined
43 }
44}
45
46/**
47 * A speaker killed without cleaning up leaves its file behind: past the
48 * piece's end (or 30 s into a phase with no known length) plus a margin, the
49 * file is taken as dead.
50 */
51export function isStale(p: VoiceProgress, now: number): boolean {
52 const expected = p.phase === 'speaking' && p.pieceMs > 0 ? p.pieceMs : 30_000
53 return now > p.at + expected + 15_000 || now < p.at - 60_000
54}
55
56/** 0..1 while speaking; undefined while the audio is still being made. */
57export function fraction(p: VoiceProgress, now: number): number | undefined {
58 if (p.phase !== 'speaking' || p.pieces < 1) return undefined
59 const within = p.pieceMs > 0 ? Math.min(1, Math.max(0, (now - p.at) / p.pieceMs)) : 0.5
60 return Math.min(1, (Math.min(p.piece, p.pieces - 1) + within) / p.pieces)
61}
62
63function gradient(t: number): string {
64 const x = Math.min(1, Math.max(0, t)) * (STOPS.length - 1)
65 const i = Math.min(STOPS.length - 2, Math.floor(x))
66 const f = x - i
67 const [a, b] = [STOPS[i], STOPS[i + 1]]
68 return `#${a.map((v, k) => Math.round(v + (b[k] - v) * f).toString(16).padStart(2, '0')).join('')}`
69}
70
71/**
72 * The bar's cells: filled to `progress` (0..1), or with `progress` undefined a
73 * comet sweeping across, moved on by `frame`. Lit cells take the gradient's
74 * color at their position; the rest show the dim track.
75 */
76export function barCells(progress: number | undefined, frame: number, width = BAR_CELLS): Cell[] {
77 const lit = new Array<number>(width).fill(0)
78 if (progress === undefined) {
79 const head = (frame % (width + COMET.length)) - COMET.length
80 COMET.forEach((level, k) => {
81 if (head + k >= 0 && head + k < width) lit[head + k] = level
82 })
83 } else {
84 let steps = Math.round(Math.min(1, Math.max(0, progress)) * width * 6)
85 for (let i = 0; i < width && steps > 0; i++, steps -= 6) lit[i] = Math.min(6, steps)
86 }
87 return lit.map((level, i) =>
88 level === 0 ? { char: TRACK, color: TRACK_COLOR } : { char: LEVELS[level], color: gradient(i / Math.max(1, width - 1)) },
89 )
90}
91
92/** Runs of cells that share a color, so a drawing needs fewer elements. */
93export function runs(cells: Cell[]): Cell[] {
94 const out: Cell[] = []
95 for (const c of cells) {
96 const last = out.at(-1)
97 if (last && last.color === c.color) last.char += c.char
98 else out.push({ ...c })
99 }
100 return out
101}
102
103/** The words beside the bar. */
104export function voiceLabel(p: VoiceProgress): string {
105 const what =
106 p.phase === 'summarizing' ? 'summarizing the reply' : p.phase === 'voicing' ? 'creating audio' : p.kind === 'explain' ? 'explaining' : 'reading the summary'
107 const part = p.phase === 'speaking' && p.pieces > 1 ? ` ${Math.min(p.piece + 1, p.pieces)}/${p.pieces}` : ''
108 return `${p.project ? `${p.project} · ` : ''}${what}${part}`
109}
110
111/**
112 * Whether this window's session is the one speaking. Progress from an older
113 * plugin names no session and counts as everyone's, as it always did.
114 */
115export function isOwnSpeech(p: VoiceProgress, sessionId: string): boolean {
116 return p.sessionId === '' || p.sessionId === sessionId
117}
118
119/** The dimmed line other windows show while a session speaks. */
120export function otherSpeechLabel(p: VoiceProgress): string {
121 return `🔊 ${voiceLabel({ ...p, project: p.project || 'Another session' })}`
122}
123types/index.d.ts 36 lines1/** A friction nudge the backend raised for this session. */
2export type StuckNudge = { rule: string; severity: string; message: string }
3
4/** What the band above the prompt shows; one thing at a time. */
5export type Band =
6 | { kind: 'stuck'; nudge: StuckNudge }
7 | { kind: 'label'; turnStartedAt: string }
8 | null
9
10/**
11 * What the Bash plugin's speaker is doing (~/.cache/devscope/voice/progress.json):
12 * `at` is when the phase or piece began (epoch ms), `pieceMs` how long the piece
13 * plays (0 when unknown), `pid` the speaker's process group. `sessionId` is the
14 * Claude Code session the speech is about ('' from older plugins: shown everywhere).
15 */
16export type VoiceProgress = {
17 kind: 'explain' | 'reply'
18 project: string
19 phase: 'summarizing' | 'voicing' | 'speaking'
20 piece: number
21 pieces: number
22 pieceMs: number
23 at: number
24 pid: number
25 sessionId: string
26}
27
28/** The voice bar: the latest progress, and a frame counter that animates it. */
29export type VoiceView = { progress: VoiceProgress; frame: number } | null
30
31declare module 'claude-code' {
32 interface PluginState {
33 'devscope-live': { band: Band; voice: VoiceView }
34 }
35}
36