SLOPSHOPPER

devscope-live

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

newbandguardtoastpromptprocess
A shopper browsing a rack in a slop shop
README

DevScope Plugin

License: MIT Cloud GitHub

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.

Quick Start

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.sh during 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

Setup

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

Server Options

OptionURLDescription
Cloud (recommended)https://devscope.shHosted for you — sign up, get an API key, done
Self-hosted (Docker)https://your-domain.comRun your own instance with Docker
Local developmenthttp://localhost:6767For contributors working on DevScope itself

Configuration

The plugin reads configuration in this priority order:

  1. Environment variables: DEVSCOPE_URL, DEVSCOPE_API_KEY
  2. Config file: ~/.config/devscope/config (or $XDG_CONFIG_HOME/devscope/config)
  3. Default: http://localhost:6767

Prerequisites

Privacy Modes

Control what data is sent to the server with the DEVSCOPE_PRIVACY setting in ~/.config/devscope/config:

ModeWhat's sentUse when
privateTool names, file paths, durations onlyMaximum privacy — no prompt or response content
standardEverything in private + prompt text + full tool inputsDefault — good balance for team insights
openEverything in standard + Claude's response textFull 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 redacted and full are automatically mapped to private and open respectively — no config changes needed.

"You've asked this before"

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.

"You've hit this error before"

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.

Next-step hints

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)

New model check

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.

Token usage and cost

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.

Search past sessions

/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 (in-session mod)

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:

  • Team prompts: after a turn in which Claude did work, a short next step (a few words, like 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.
  • Team skills: when a prompt matches one of your team's approved skills, you're asked whether to use it. Choosing it attaches the skill to that prompt.
  • Stuck band: when Claude keeps failing the same way, a band above the prompt offers Step back (interrupt and ask Claude to reassess), Stop or Keep going.
  • Outcome labels: replies like "that didn't work" or "thanks" label the previous turn, and a long turn ends with "Did that work?". This tells DevScope which work actually succeeded.
  • Commit and PR links: commits and PRs a session makes are recorded against it, and whether its PRs merged is checked with your own gh. An optional DevScope-Session: trailer (off by default) marks Claude's commits and PRs.
  • Voice progress bar: while DevScope voice summarizes, creates or speaks audio, a colored 6-dot braille bar above the prompt shows how far along it is, with a Stop button.
/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.

Voice announcements

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."

  • Only when you've lost track. It speaks after a grace delay (30 s for permission prompts and questions, 10 s for failed turns). Answer in time and it stays silent. If you don't, it reminds you every 5 minutes, up to 3 times.
  • Says which session. Every announcement and reply summary starts with the session's name: the project plus what it is working on, from the session's title or its git branch ("devscope-cloud, rate limiter fix"). A session keeps the same name for its whole life, so with several running you know which one is talking.
  • Across all sessions. Announcements play one at a time, and three or more at once become one sentence ("three sessions need you: rate limiter fix in devscope-cloud, oauth login in web and docs").
  • Summaries follow your privacy mode. Sentences are written by DevScope's AI from what your privacy mode already sends. private sessions never leave your machine and use a local template, named from the local git branch.
  • Natural voice, nothing to install. Speech is voiced by your DevScope server. Which voices it offers is up to the server; the homelab offers Chatterbox Turbo (the default, on its GPU) and Kokoro (on its CPU, also the fallback when the GPU is busy). /devscope:voice model lists them, /devscope:voice model kokoro picks one for you, model default goes back to the server's choice.
  • Local mode, for Macs. /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.

How detailed

/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.

shortnormal (default)long
Auto voiceone sentence, the outcometwo or three sentencesfour to six: what changed, why, what's next
Explainabout 40 secondsabout a minute and a halfabout three minutes

/devscope:voice explain --short <topic> (or --long) overrides it for one explanation.

Locked screen

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 voiceskipped; the reply is on screen when you are back
An explanationstops 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

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.

Explain it out loud

/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

What's Tracked

EventData Sent
Session start/endSession duration, permission mode
Tool useTool name, duration, success/failure
Prompt submitPrompt length
Subagent start/stopAgent type, task description (not in private mode) and model
Response completeTools used, response length
Task completedTask 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.

Platform Support

Works on Linux and macOS. Cross-platform compatibility is handled automatically for:

  • SHA256 hashing (sha256sum / shasum / openssl)
  • Nanosecond timestamps (GNU date / python3 / perl fallback)
  • UUID generation (/proc/sys/kernel/random/uuid / uuidgen)

Links

Troubleshooting

Events not appearing in dashboard

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.

Plugin not running

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

Update not taking effect

Claude Code caches plugins by version. After updating:

claude plugin update devscope
# Restart Claude Code for changes to take effect

Contributing

For plugin-specific changes, open a PR here. For server/dashboard changes, see the main DevScope repo.

See CONTRIBUTING.md for guidelines.

License

MIT

Source 8 files
hooks/register.tsx 475 lines
1import { 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}
475
hooks/config.ts 81 lines
1import 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}
81
hooks/labels.ts 28 lines
1export 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}
28
hooks/suggestions.ts 68 lines
1/** 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
68
hooks/teamSkills.ts 44 lines
1export 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}
44
hooks/vcs.ts 59 lines
1export 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}
59
hooks/voiceBar.ts 123 lines
1import 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}
123
types/index.d.ts 36 lines
1/** 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