SLOPSHOPPER

claude-plugin

Whisper but better

newbandspinnerguardcommandtoast
A shopper browsing a rack in a slop shop
README

WhisperM8

<img src="whisperm8-logo.png" alt="WhisperM8 Logo" width="128" height="128">

<strong>Native macOS app: AI-powered dictation + a tabbed session manager for Claude Code &amp; Codex CLI</strong>

<img src="https://img.shields.io/badge/macOS-14%2B-blue?style=flat-square" alt="macOS 14+"> <img src="https://img.shields.io/badge/Swift-5.9-orange?style=flat-square" alt="Swift 5.9"> <img src="https://img.shields.io/badge/License-MIT-green?style=flat-square" alt="License">


Features

WhisperM8 has two halves that share one app.

🎙️ Dictation

  • Toggle Recording — Press a hotkey to start, press again to stop and transcribe
  • Auto-Paste — Transcribed text is automatically pasted into the active app (optional)
  • Dual Provider Support — Choose between OpenAI Whisper or Groq
  • Visual Context — Optionally attach a screenshot (screencapture -i) as context for AI post-processing
  • Output Modes — Rewrite, email, Slack, … with optional Codex-CLI post-processing
  • whisperm8 CLI — Transcribe audio/video files from the terminal (or let your AI agent do it — see CLI)
  • Menu Bar App — Runs quietly in your menu bar; API keys stored in the macOS Keychain

🤖 Agent Chats

A session manager for Claude Code and Codex CLI:

  • Chrome-like tabs — Full PTY terminals in tabs with multi-select (Cmd/Shift-click), drag & drop reordering, and tear-off into a new window
  • Clickable links — Cmd-click a path in the terminal to open it: code/Markdown → PhpStorm, other files → default app, folders → Finder; Cmd+Alt reveals in Finder
  • Background agents — Spawn and attach claude --bg agents
  • Live status & organization — Per-session working / awaiting-input / idle status, project grouping, pinning, and color tags
  • Transcript browsing — Read closed Claude/Codex sessions inside the app

Installation

Homebrew

Install:

brew install --cask rankm8/tap/whisperm8

Update to the latest version:

brew upgrade --cask whisperm8

(Equivalent install: brew tap rankm8/tap && brew install --cask whisperm8.)

WhisperM8 is distributed self-signed (no Apple Developer ID / notarization). The cask automatically removes the Gatekeeper quarantine attribute on install, so the app launches without a manual override.

Setup

  1. Launch WhisperM8 — the setup wizard walks you through the rest.
  2. Choose a profile — Dictation only, Dictation + AI enrichment or Full (with Agent Chats). You can change it later under Settings → General.
  3. Grant permissions — Microphone (recording) and Accessibility (auto-paste).
  4. Set your hotkey — e.g. Control + Shift + Space.
  5. Enter an API key — from Groq (free tier) or OpenAI; stored in the macOS Keychain.
  6. Connect Claude Code and Codex (AI enrichment and Agent Chats only) — the wizard detects existing installations. If a CLI is missing, it offers to install it via Homebrew (brew install --cask claude-code / codex) after you confirm; Codex is connected right in the wizard (Sign in with ChatGPT); Claude Code walks you through signing in the first time you start a chat.
  7. Try it — the last step records a short test dictation.

Usage

  1. Place your cursor in any text field
  2. Press your hotkey to start recording
  3. Speak your text
  4. Press the hotkey again to stop and transcribe
  5. Text appears automatically (or is copied to clipboard)

Cancel Recording: Click the X button in the overlay.

CLI: whisperm8

The app installs a command-line companion at ~/.local/bin/whisperm8 (a symlink to the app binary, refreshed on every launch). It transcribes audio and video files using the same API key the app stores in the Keychain — no separate login.

whisperm8 transcribe recording.m4a                     # audio → text (stdout)
whisperm8 transcribe talk.mp4 -f srt -o talk.srt       # video → subtitles
whisperm8 transcribe call.mp3 --mode clean -o call.txt # + post-processing mode
whisperm8 transcribe workshop.mp4 --dry-run            # duration + cost estimate

Formats: txt, json, srt, vtt · providers: Groq (default) and OpenAI · long files are chunked at silence boundaries and stitched back together automatically. Run whisperm8 --help for all options.

Agent skill: Settings → CLI & Skills provides an agent skill (SKILL.md) that teaches Claude Code, Claude.ai, or ChatGPT how to drive the CLI. Claude sessions started in WhisperM8 get it automatically (as part of the WhisperM8 plugin); for Claude Code in Terminal use Install for Terminal…, for other assistants copy or save it. Source of truth: WhisperM8/Resources/whisperm8-cli-skill.md.

API Providers

ProviderPriceSpeedLink
GroqFree (rate-limited)Very FastGet API Key
OpenAI~$0.006/minFastGet API Key

Troubleshooting

For detailed troubleshooting, see docs/README.md.

Development

Building from source, make targets, resets and releases are for contributors only — see docs/DEVELOPMENT.md. End users install and update exclusively via Homebrew.

Documentation

Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

Credits

Built and open-sourced by 360WebManager — Your partner for web development and digital solutions.

License

MIT License — see LICENSE for details.


Made with ❤️ for the macOS community

Source 3 files
hooks/register.tsx 707 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { BoardEntry, BoardLight, BoardView } from '../types'
5import { registerTasks, startTasks } from './tasks'
6
7// Jarvis-Board: Übersicht der Chats, die Jarvis aktiv betreut, als Band über
8// dem Prompt. Die Wahrheit liegt in der App (`whisperm8 chats board`), die Mod
9// zeigt nur an, hört per `chats watch` auf Änderungen und weckt Jarvis.
10// Plan und Messwerte: docs/plans/whisperm8-plugin.md (Prototyp 08.10.2026).
11//
12// Ausgeschaltet (kein aktives Board dieser Session) zeichnet die Mod nichts und
13// startet keinen Prozess; das Tool steht dann nur hinter ToolSearch.
14//
15// Zweiter Teil (MARK: Voice-Callouts): Claude spricht am Turn-Ende eine kurze
16// Ansage über die App (`whisperm8 speak`). Eigentlich als callout.tsx geplant,
17// aber die Engine folgt `$` und `on` nie über einen Import und erlaubt je
18// Plugin nur einen `session.start` ohne Matcher — deshalb in dieser Datei.
19
20const board = atom({ plugin: 'whisperm8', key: 'board' } as const, null as BoardView | null)
21const folded = atom({ plugin: 'whisperm8', key: 'folded' } as const, false)
22const now = atom({ plugin: 'whisperm8', key: 'now' } as const, 0)
23const woken = atom({ plugin: 'whisperm8', key: 'woken' } as const, [] as string[])
24const callout = atom({ plugin: 'whisperm8', key: 'callout' } as const, false)
25
26const TOOL = 'mcp__whisperm8__board'
27const JARVIS_SKILL = 'whisperm8:jarvis'
28const SPEAK_TOOL = 'mcp__whisperm8__speak'
29const CALLOUT_SKILL = 'whisperm8:callout'
30const MIN = 60_000
31/** Mehrere Weck-Gründe kurz hintereinander werden zu einem Prompt gebündelt. */
32const WAKE_COALESCE_MS = 5_000
33/** Höchstens ein Weck-Prompt je Minute: Jeder ist ein Turn von Jarvis. */
34const WAKE_MIN_GAP_MS = 60_000
35
36// Modul-Zustand: lebt nur bis zum nächsten Reload, deshalb nur Steuerung,
37// nie Anzeige (die steht in den Atomen oben).
38let sessionID: string | null = null
39let binary = 'whisperm8'
40let watchGeneration = 0
41let watchStream: { return?: (value?: undefined) => unknown } | null = null
42/** Läuft eine watch-Schleife (auch während ihrer Backoff-Pause)? */
43let watchRunning = false
44let sessionEnded = false
45let refreshPending = false
46/** Der 30-s-Takt der Band-Uhr läuft einmal je Mod-Instanz — session.start feuert bei Start, Resume, /clear und Compact erneut. */
47let tickerStarted = false
48let toolDeferred: boolean | null = null
49let speakToolDeferred: boolean | null = null
50let wakeQueue: string[] = []
51let wakeTimerArmed = false
52let lastWakeAt = 0
53
54const ORDER: Record<BoardLight, number> = { needsYou: 0, done: 1, running: 2, parked: 3 }
55const COLOR: Record<BoardLight, string> = { needsYou: 'error', running: 'warning', done: 'success', parked: 'inactive' }
56const GLYPH: Record<BoardLight, string> = { needsYou: '●', running: '●', done: '●', parked: '○' }
57const LABEL: Record<BoardLight, string> = { needsYou: 'wartet', running: 'läuft', done: 'fertig', parked: 'geparkt' }
58
59// MARK: CLI
60
61type CLIResult = { ok: boolean; stdout: string; message: string }
62
63const cli = async ($: EngineInterface, args: string[], timeoutMs = 15_000): Promise<CLIResult> => {
64  const run = (bin: string) => $.process.run([bin, ...args], { timeoutMs })
65  let result
66  try {
67    result = await run(binary)
68  } catch {
69    // PATH ohne ~/.local/bin (Session außerhalb einer Login-Shell): absolut.
70    const home = await $.env.get('HOME')
71    binary = `${home ?? ''}/.local/bin/whisperm8`
72    try {
73      result = await run(binary)
74    } catch (error) {
75      return { ok: false, stdout: '', message: `whisperm8 nicht ausführbar: ${String(error)}` }
76    }
77  }
78  const message = (result.stderr.trim() || result.stdout.trim()).split('\n')[0] ?? ''
79  return { ok: result.exitCode === 0, stdout: result.stdout, message }
80}
81
82const parseBoard = (stdout: string): BoardView | null => {
83  try {
84    const raw = JSON.parse(stdout) as Partial<BoardView>
85    if (typeof raw.owner !== 'string' || !Array.isArray(raw.entries)) return null
86    return {
87      owner: raw.owner,
88      ownerRef: raw.ownerRef ?? raw.owner.slice(0, 8).toLowerCase(),
89      isActive: raw.isActive === true,
90      cursor: typeof raw.cursor === 'string' ? raw.cursor : null,
91      entries: raw.entries as BoardEntry[],
92    }
93  } catch {
94    return null
95  }
96}
97
98// MARK: Board lesen
99
100const refresh = async ($: EngineInterface) => {
101  const ran = await cli($, ['chats', 'board', '--json'])
102  if (!ran.ok) return
103  const next = parseBoard(ran.stdout)
104  if (next === null) return
105  await update($, board, () => next)
106  const at = await $.clock.now()
107  await update($, now, () => at)
108  // Bewusst keine Statuszeile: Claude Code setzt „⚠“ davor, sie sah nach
109  // einer Warnung aus und wiederholte nur die Zahlen aus dem Band.
110  // Scheitert die Tool-Anmeldung, laufen Band und Wecken trotzdem.
111  await syncTool($, next.isActive).catch(() => undefined)
112  if (next.isActive) {
113    if (!watchRunning) startWatch($)
114  } else {
115    stopWatch()
116  }
117}
118
119/** Mehrere Ereignisse kurz hintereinander lösen EIN Lesen aus. */
120const scheduleRefresh = ($: EngineInterface) => {
121  if (refreshPending) return
122  refreshPending = true
123  $.clock.after(400, () => {
124    refreshPending = false
125    void refresh($).catch(() => undefined)
126  })
127}
128
129const counts = (entries: readonly BoardEntry[]) => {
130  const c: Record<BoardLight, number> = { needsYou: 0, running: 0, done: 0, parked: 0 }
131  for (const entry of entries) c[entry.light] = (c[entry.light] ?? 0) + 1
132  return c
133}
134
135/**
136 * Kopfzeile so ausführlich, wie sie in `room` Spalten passt: erst wird der
137 * Bedien-Hinweis rechts kürzer, dann fällt „· Board“ weg, dann der Hinweis,
138 * zuletzt die Wörter außer „wartet“, das ist die Zahl, die zählt.
139 * Feste Breitenstufen reichten nicht: Bei 67 Spalten brach der Kopf um.
140 */
141const fitHeader = (c: Record<BoardLight, number>, room: number, title: string, withBoard: boolean, hints: string[]) => {
142  const parts = (words: boolean) =>
143    (['needsYou', 'running', 'done', 'parked'] as const)
144      .filter(l => c[l] > 0)
145      .map(l => ({ light: l, text: `${GLYPH[l]} ${c[l]}${words || l === 'needsYou' ? ` ${LABEL[l]}` : ''}  ` }))
146  const fits = (head: string, words: boolean, hint: string) =>
147    head.length + 2 + parts(words).reduce((n, p) => n + p.text.length, 0) + hint.length <= room
148  const heads = withBoard ? [`${title} · Board`, title] : [title]
149  for (const head of heads) {
150    for (const hint of hints) if (fits(head, true, hint)) return { head, parts: parts(true), hint }
151  }
152  for (const [head, words] of heads.map(h => [h, true] as const).concat([[title, false]])) {
153    if (fits(head, words, '')) return { head, parts: parts(words), hint: '' }
154  }
155  return { head: title, parts: parts(false), hint: '' }
156}
157
158/**
159 * Bedien-Hinweise statt Knöpfen „z: zu“ / „a: auf“: Buchstaben-Hotkeys
160 * greifen nur nach ctrl+x tab, und Klicks meldet Claude Code nur im
161 * Vollbild-Terminal; App-Sessions laufen im Main-Screen. Eine Ziffer im
162 * leeren Prompt geht dagegen überall.
163 */
164const OPEN_HINTS = ['Ziffer = Tab öffnen', 'Ziffer: Tab']
165const FOLDED_HINTS = ['/board auf', '/board']
166
167
168/** Aktiv: Schema ab Turn 1 in der Liste. Sonst nur der Name (ToolSearch). */
169const syncTool = async ($: EngineInterface, active: boolean) => {
170  const deferred = !active
171  if (toolDeferred === deferred) return
172  toolDeferred = null
173  await $.tool.register({
174    name: 'board',
175    description:
176      'Jarvis-Board pflegen: die Chats, die du gerade aktiv betreust. Die App zeigt daraus auch die Notiz-Karte am Kachelkopf. set: Ampel (needsYou | running | done | parked), mission (Ziel), work (Arbeitspaket), needs (Eingriff des Users), blocker. milestone: ein erreichtes Ergebnis anhängen (text). Nur bei echten Änderungen schreiben, nie Einzelschritte. action: set | milestone | remove | clear | activate | deactivate | read. ref = Kurz-Ref des Chats wie in whisperm8 chats list.',
177    inputSchema: {
178      type: 'object',
179      properties: {
180        action: { type: 'string', enum: ['set', 'milestone', 'remove', 'clear', 'activate', 'deactivate', 'read'] },
181        ref: { type: 'string', description: 'Chat-Ref (für set, milestone und remove)' },
182        light: { type: 'string', enum: ['needsYou', 'running', 'done', 'parked'] },
183        mission: { type: 'string', description: 'Ziel: der Auftrag des Chats in einem Satz' },
184        work: {
185          type: 'string',
186          description: 'Arbeitspaket: woran der Chat arbeitet, ein Ergebnis für die nächsten 30 min bis wenige Stunden — kein Einzelschritt',
187        },
188        needs: { type: 'string', description: 'Eingriff: Frage oder Entscheidung an den User (bei needsYou), sonst leer' },
189        blocker: { type: 'string', description: 'Was den Chat aufhält, ohne dass der User gefragt ist; leer = keine' },
190        text: { type: 'string', description: 'Meilenstein (milestone): Commit, Tests grün, Entscheidung gefallen, übergeben' },
191      },
192      required: ['action'],
193    },
194    isDeferred: deferred,
195  })
196  toolDeferred = deferred
197}
198
199// MARK: Live über `chats watch`
200
201type WatchEvent = {
202  event?: string
203  gap?: boolean
204  kind?: string
205  owner?: string
206  sessionID?: string
207  from?: string | null
208  to?: string | null
209}
210
211const sameID = (a: string | undefined, b: string | undefined) =>
212  a !== undefined && b !== undefined && a.toUpperCase() === b.toUpperCase()
213
214const stopWatch = () => {
215  watchGeneration += 1
216  watchRunning = false
217  const stream = watchStream
218  watchStream = null
219  void stream?.return?.(undefined)
220}
221
222const sleep = ($: EngineInterface, ms: number) => new Promise<void>(resolve => void $.clock.after(ms, resolve))
223
224/** Ein Kind für die Session; endet es, mit Backoff und frischem Cursor neu. */
225const startWatch = ($: EngineInterface) => {
226  const generation = ++watchGeneration
227  watchRunning = true
228  void (async () => {
229    let delay = 2_000
230    while (generation === watchGeneration && !sessionEnded) {
231      const cursor = (await read($, board))?.cursor ?? null
232      const argv = [binary, 'chats', 'watch', '--interval', '2']
233      if (cursor !== null) argv.push('--cursor', cursor)
234      const iterator = $.process.spawn({ argv })[Symbol.asyncIterator]()
235      watchStream = iterator
236      let buffer = ''
237      try {
238        for (;;) {
239          const step = await iterator.next()
240          if (step.done || generation !== watchGeneration) break
241          const chunk = step.value
242          if (chunk.stream !== 'stdout') continue
243          buffer += chunk.text
244          const lines = buffer.split('\n')
245          buffer = lines.pop() ?? ''
246          for (const line of lines) {
247            if (line.trim() === '') continue
248            try {
249              await handleEvent($, JSON.parse(line) as WatchEvent)
250              delay = 2_000
251            } catch {
252              // Eine kaputte Zeile beendet den Strom nicht.
253            }
254          }
255        }
256      } catch {
257        // Start gescheitert oder Kind gestorben: unten neu ansetzen.
258      }
259      if (generation !== watchGeneration || sessionEnded) break
260      watchStream = null
261      await sleep($, delay)
262      delay = Math.min(delay * 2, 60_000)
263      // Frischer Stand samt Cursor, bevor der nächste Strom ansetzt.
264      await refresh($).catch(() => undefined)
265    }
266  })()
267    .catch(() => undefined)
268    .finally(() => {
269      if (generation === watchGeneration) watchRunning = false
270    })
271}
272
273const handleEvent = async ($: EngineInterface, ev: WatchEvent) => {
274  if (ev.gap === true || ev.event === 'gap') {
275    scheduleRefresh($)
276    return
277  }
278  if (ev.event !== undefined) return
279  if (ev.kind === 'board') {
280    if (sameID(ev.owner, sessionID ?? undefined)) scheduleRefresh($)
281    return
282  }
283  const view = await read($, board)
284  const entry = view?.entries.find(e => sameID(e.sessionID, ev.sessionID))
285  if (entry === undefined) return
286  scheduleRefresh($)
287  await considerWake($, entry, ev)
288}
289
290// MARK: Wecken
291
292/** Wartet auf den User oder hat den Turn beendet: dann lohnt ein Blick. */
293const wakeReason = (ev: WatchEvent): string | null => {
294  if (ev.to === 'awaitingInput') return 'wartet auf dich'
295  if (ev.from === 'working' && ev.to === 'idle') return 'fertig – prüfen'
296  return null
297}
298
299let wakeMode = 'turn'
300
301const considerWake = async ($: EngineInterface, entry: BoardEntry, ev: WatchEvent) => {
302  const id = entry.sessionID.toUpperCase()
303  if (ev.to === 'working') {
304    // Arbeitet wieder: derselbe Zustand darf später erneut wecken.
305    await update($, woken, list => (list ?? []).filter(key => !key.startsWith(`${id}:`)))
306    return
307  }
308  const reason = wakeReason(ev)
309  if (reason === null || entry.light === 'parked' || wakeMode === 'aus') return
310  const key = `${id}:${ev.to}`
311  if ((await read($, woken)).includes(key)) return
312  await update($, woken, list => [...(list ?? []), key].slice(-200))
313  // Der Titel stammt aus einem fremden Chat (ai-title, erster Prompt) — als Daten
314  // gekennzeichnet und ohne Steuerzeichen, nie als Anweisung lesbar (LR-PJ-004).
315  wakeQueue.push(`[Board] „${asData(entry.title)}“ (${asData(entry.ref)}): ${reason}`)
316  armWake($)
317}
318
319const armWake = ($: EngineInterface) => {
320  if (wakeTimerArmed) return
321  wakeTimerArmed = true
322  const wait = Math.max(WAKE_COALESCE_MS, lastWakeAt + WAKE_MIN_GAP_MS - Date.now())
323  $.clock.after(wait, () => void flushWake($).catch(() => undefined))
324}
325
326const flushWake = async ($: EngineInterface) => {
327  wakeTimerArmed = false
328  const lines = wakeQueue
329  wakeQueue = []
330  if (lines.length === 0) return
331  lastWakeAt = Date.now()
332  $.ui.toast(lines.length === 1 ? lines[0]! : `${lines[0]} (+${lines.length - 1})`)
333  if (wakeMode !== 'turn') return
334  await $.prompt.submit({ text: `${lines.join('\n')}\nStand prüfen und das Board nachführen.` })
335}
336
337// MARK: Anzeige
338
339const age = (iso: string, at: number, short: boolean) => {
340  const since = Date.parse(iso)
341  const m = Number.isNaN(since) ? 0 : Math.max(0, Math.floor((at - since) / MIN))
342  if (m < 1) return 'jetzt'
343  if (m < 60) return short ? `${m}m` : `${m} min`
344  const h = Math.floor(m / 60)
345  if (h < 24) return short ? `${h}h` : `${h} h`
346  return short ? `${Math.floor(h / 24)}d` : `${Math.floor(h / 24)} d`
347}
348
349/** Arbeitspaket; CLIs von vor dem Feld liefern es nur unter `next`. */
350const workOf = (e: BoardEntry) => e.work || e.next
351
352/** Beim Warten das Anliegen, sonst Blockade oder Arbeitspaket (Rückfall: Auftrag). */
353const say = (e: BoardEntry) => {
354  const work = workOf(e) || e.mission
355  if (e.light === 'needsYou') return e.needs || work
356  if (e.light === 'done') return work ? `fertig · ${work}` : 'fertig'
357  if (e.blocker) return `hängt · ${e.blocker}`
358  return work
359}
360
361const sorted = (entries: readonly BoardEntry[]) =>
362  [...entries].sort((a, b) => ORDER[a.light] - ORDER[b.light] || Date.parse(a.updatedAt) - Date.parse(b.updatedAt))
363
364// Steuerzeichen (C0, DEL, C1) und Anführungszeichen raus, auf eine Zeile, gekürzt.
365const asData = (text: string) => clip(String(text ?? '').replace(/[\u0000-\u001f\u007f-\u009f]+/g, ' ').replace(/[„“"]/g, "'").trim(), 120)
366
367const clip = (text: string, width: number) => (text.length > width ? `${text.slice(0, Math.max(0, width - 1))}…` : text)
368
369/**
370 * Holt den Tab des Chats nach vorn, direkt per CLI und ohne Turn von Jarvis:
371 * Der User hat ausdrücklich gedrückt. (Jarvis selbst nutzt im Aktiv-Workspace
372 * nie `chats open`, das reißt die Workspace-Ansicht weg.)
373 */
374const openChat = async ($: EngineInterface, entry: BoardEntry) => {
375  const ran = await cli($, ['chats', 'open', entry.ref])
376  $.ui.toast(ran.ok ? `Geöffnet: ${entry.title}` : `${entry.title}: ${ran.message || 'Öffnen fehlgeschlagen'}`)
377}
378
379// MARK: Voice-Callouts
380
381/** Bei aktivem Modus steht `speak` direkt in der Liste, sonst hinter ToolSearch. */
382const syncSpeakTool = async ($: EngineInterface, active: boolean) => {
383  const deferred = !active
384  if (speakToolDeferred === deferred) return
385  speakToolDeferred = null
386  await $.tool.register({
387    name: 'speak',
388    description:
389      'Kurze gesprochene Ansage an den User am Ende des Turns (Skill whisperm8:callout): nur wenn der Turn auf ihn wartet, eine längere Aufgabe fertig ist oder du festhängst. text = höchstens zwei Sätze Deutsch, ohne Pfade, Code, IDs oder Markdown; den Namen des Chats stellt die App voran.',
390    inputSchema: {
391      type: 'object',
392      properties: {
393        text: { type: 'string', description: 'Höchstens zwei kurze Sätze, gesprochene Sprache' },
394      },
395      required: ['text'],
396    },
397    isDeferred: deferred,
398  })
399  speakToolDeferred = deferred
400}
401
402const setCallout = async ($: EngineInterface, active: boolean) => {
403  await update($, callout, () => active)
404  await syncSpeakTool($, active).catch(() => undefined)
405}
406
407/** Skill geladen: Modus an, Hinweis für das Modell an den Skill-Text. */
408const calloutOnSkill = async ($: EngineInterface, text: string): Promise<string> => {
409  if (sessionID === null) return text
410  await setCallout($, true)
411  return `${text}\n\n[whisperm8] Ansagen sind in dieser Session an: am Turn-Ende bei Bedarf ${SPEAK_TOOL} (Regeln im Skill ${CALLOUT_SKILL}). /callout aus schaltet sie ab.`
412}
413
414type SpeakReply = { status?: string }
415
416/** Die App antwortet, sobald die Ansage in der Warteschlange steht: der Turn blockiert nicht. */
417const speak = async ($: EngineInterface, text: string): Promise<string> => {
418  const ran = await cli($, ['speak', text], 10_000)
419  if (!ran.ok) return `FEHLER: ${ran.message}`
420  let reply: SpeakReply = {}
421  try {
422    reply = JSON.parse(ran.stdout) as SpeakReply
423  } catch {
424    // Ohne lesbare Antwort gilt die Ansage als angenommen (Exit 0).
425  }
426  switch (reply.status) {
427    case 'muted':
428      $.ui.toast('🔇 stumm, nicht vorgelesen')
429      return 'muted: Der User hat Ansagen stumm geschaltet. Nicht erneut versuchen.'
430    case 'debounced':
431      return 'debounced: Dieser Chat hat eben erst gesprochen. Nicht erneut versuchen.'
432    default:
433      $.ui.toast('🔊 vorgelesen')
434      return `${reply.status ?? 'queued'}: wird vorgelesen.`
435  }
436}
437
438// MARK: Hooks
439
440export const register: Register = (on, options) => {
441  wakeMode = typeof options.wecken === 'string' ? options.wecken : 'turn'
442  // Task-Zähler und Link-Hinweis (tasks.tsx).
443  registerTasks(on, options)
444
445  on('session.start', async ($, e, next) => {
446    const started = await next(e)
447    sessionEnded = false
448    sessionID = (await $.env.get('WHISPERM8_SESSION_ID')) ?? null
449    // Der Task-Zähler gilt in jeder Session, auch außerhalb von WhisperM8.
450    await startTasks($).catch(() => undefined)
451    // Außerhalb eines WhisperM8-Chats gibt es weder Board noch Ansagen.
452    if (sessionID === null) return started
453    try {
454      await $.command.register({ name: 'board', description: 'Jarvis-Board ein-/ausklappen; an | aus | zu | auf' })
455      await $.command.register({ name: 'callout', description: 'Gesprochene Ansagen am Turn-Ende: an | aus' })
456    } catch {
457      // Ohne Befehl bleiben Band und Tool.
458    }
459    // Callout-Modus nach Neustart oder Reload: er steht in $.state und bleibt.
460    await syncSpeakTool($, await read($, callout)).catch(() => undefined)
461    try {
462      await syncTool($, false)
463    } catch {
464      // Ohne Tool pflegt Jarvis das Board per CLI.
465    }
466    // Nach Neustart oder Reload: ein aktives Board ist sofort wieder da.
467    // Nicht awaiten: das Lesen startet einen CLI-Prozess (App-Binary im
468    // CLI-Modus), der den Chatstart sonst bremst — das Band erscheint, sobald
469    // die Antwort da ist (Atom-Update).
470    void refresh($).catch(() => undefined)
471    if (!tickerStarted) {
472      tickerStarted = true
473      $.clock.every(30_000, () => void $.clock.now().then(at => update($, now, () => at)).catch(() => undefined))
474    }
475    return started
476  })
477
478  on('session.end', async ($, e, next) => {
479    sessionEnded = true
480    stopWatch()
481    return next(e)
482  })
483
484  // Jarvis startet: Board einschalten, unabhängig davon, ob das Modell daran denkt.
485  // Jarvis spricht außerdem immer: Callout-Modus mit an.
486  on('skill.prompt', { skill: JARVIS_SKILL }, async ($, e, next) => {
487    const result = await next(e)
488    if (sessionID === null) return result
489    const text = await calloutOnSkill($, result.text).catch(() => result.text)
490    const ran = await cli($, ['chats', 'board', 'activate', '--json'])
491    if (!ran.ok) return { text }
492    await refresh($).catch(() => undefined)
493    return {
494      text: `${text}\n\n[whisperm8] Das Jarvis-Board dieser Session ist aktiv. Pflege es mit dem Tool ${TOOL} (oder whisperm8 chats board …).`,
495    }
496  })
497
498  on('skill.prompt', { skill: CALLOUT_SKILL }, async ($, e, next) => {
499    const result = await next(e)
500    return { text: await calloutOnSkill($, result.text) }
501  })
502
503  on('tool.call', { tool: SPEAK_TOOL }, async ($, e) => {
504    const input = e as unknown as { text?: string }
505    if (!(await read($, callout))) return { result: 'aus: Ansagen sind in dieser Session aus (/callout an).' }
506    const text = (input.text ?? '').trim()
507    if (text === '') return { result: 'FEHLER: text fehlt.' }
508    return { result: await speak($, text) }
509  })
510
511  on('command.run', { command: 'callout' }, async ($, e) => {
512    const arg = e.args.trim()
513    if (arg === 'an' || arg === 'aus') {
514      await setCallout($, arg === 'an')
515      return { text: arg === 'an' ? 'Ansagen an.' : 'Ansagen aus.' }
516    }
517    return { text: `Ansagen sind ${(await read($, callout)) ? 'an' : 'aus'}. /callout an | aus` }
518  })
519
520  on('tool.call', { tool: TOOL }, async ($, e) => {
521    const input = e as unknown as {
522      action?: string
523      ref?: string
524      light?: string
525      mission?: string
526      work?: string
527      next?: string
528      needs?: string
529      blocker?: string
530      text?: string
531    }
532    const action = input.action ?? 'read'
533    let args: string[]
534    switch (action) {
535      case 'set': {
536        if (!input.ref) return { result: 'FEHLER: set braucht ref.' }
537        args = ['chats', 'board', 'set', input.ref]
538        if (input.light) args.push('--light', input.light)
539        if (input.mission !== undefined) args.push('--mission', input.mission)
540        // `next` ist der frühere Name von `work` (ältere Jarvis-Turns).
541        const work = input.work ?? input.next
542        if (work !== undefined) args.push('--work', work)
543        if (input.needs !== undefined) args.push('--needs', input.needs)
544        if (input.blocker !== undefined) args.push('--blocker', input.blocker)
545        break
546      }
547      case 'milestone':
548        if (!input.ref || !input.text?.trim()) return { result: 'FEHLER: milestone braucht ref und text.' }
549        args = ['chats', 'board', 'milestone', input.ref, input.text]
550        break
551      case 'remove':
552        if (!input.ref) return { result: 'FEHLER: remove braucht ref.' }
553        args = ['chats', 'board', 'remove', input.ref]
554        break
555      case 'clear':
556      case 'activate':
557      case 'deactivate':
558        args = ['chats', 'board', action]
559        break
560      default:
561        args = ['chats', 'board']
562    }
563    const ran = await cli($, [...args, '--json'])
564    await refresh($).catch(() => undefined)
565    return { result: ran.ok ? ran.stdout.trim() || 'ok' : `FEHLER: ${ran.message}` }
566  })
567
568  // Kein Detail-Panel: In App-Sessions setzt Claude ein Pane als zweiten Kasten
569  // über das Band, die Chats stehen dann doppelt da. Details zeigt die
570  // Notiz-Karte am Kachelkopf.
571  on('command.run', { command: 'board' }, async ($, e) => {
572    const arg = e.args.trim()
573    if (arg === '' || arg === 'zu' || arg === 'auf') {
574      const fold = arg === '' ? !(await read($, folded)) : arg === 'zu'
575      await update($, folded, () => fold)
576      return { text: fold ? 'Board eingeklappt.' : 'Board ausgeklappt.' }
577    }
578    if (arg === 'an' || arg === 'aus') {
579      const ran = await cli($, ['chats', 'board', arg === 'an' ? 'activate' : 'deactivate', '--json'])
580      await refresh($).catch(() => undefined)
581      if (!ran.ok) return { text: `Board nicht umgeschaltet: ${ran.message}` }
582      return { text: arg === 'an' ? 'Board an.' : 'Board aus.' }
583    }
584    return { text: 'Unbekannt. /board klappt das Band ein und aus, dazu gibt es an, aus, zu und auf.' }
585  })
586
587  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
588    if (e.props.hasSurvey) return next(e)
589    const view = await read($, board)
590    if (view === null || !view.isActive) return next(e)
591    const t = await read($, now)
592    const isFolded = await read($, folded)
593    const { Box, Text, Button } = $.ui.resolve(e)
594    const cols = e.props.bodyColumns
595    const list = sorted(view.entries)
596    const c = counts(list)
597    const wide = cols >= 90
598    const narrow = cols < 64
599    // Platz für Titel, Zahlen und Hinweis: eine Spalte Luft, offen
600    // zusätzlich ohne Rahmen und Innenabstand.
601    const header = isFolded
602      ? fitHeader(c, cols - 1, '▸ Jarvis', false, FOLDED_HINTS)
603      : fitHeader(c, cols - 5, 'Jarvis', true, OPEN_HINTS)
604    const hint = header.hint ? <Text dimColor>{header.hint}</Text> : null
605    const summary = header.parts.map(p => (
606      <Text color={COLOR[p.light]} bold={p.light === 'needsYou'}>
607        {p.text}
608      </Text>
609    ))
610
611    if (list.length === 0) {
612      return (
613        <Box flexDirection="row" width={cols}>
614          <Text color="claude" bold>
615            Jarvis{'  '}
616          </Text>
617          <Text dimColor>Board aktiv, noch keine Chats</Text>
618        </Box>
619      )
620    }
621
622    if (isFolded) {
623      return (
624        <Box flexDirection="row" width={cols}>
625          <Text color="claude" bold>
626            {header.head}
627            {'  '}
628          </Text>
629          <Box flexDirection="row" flexGrow={1}>
630            {summary}
631          </Box>
632          {hint}
633        </Box>
634      )
635    }
636
637    // Rahmen + Kopf = 3 Zeilen; der Rest gehört den Chats.
638    const room = Math.max(1, e.props.maxRows - 3)
639    const shown = list.length > room ? list.slice(0, room - 1) : list
640    const hidden = list.length - shown.length
641    const nameWidth = wide ? 22 : narrow ? 15 : 18
642    const ageWidth = wide ? 8 : 4
643    const border = c.needsYou > 0 ? 'error' : c.done > 0 ? 'success' : 'inactive'
644
645    return (
646      <Box flexDirection="column" borderStyle="round" borderColor={border} paddingX={1} width={cols}>
647        <Box flexDirection="row">
648          <Text color="claude" bold>
649            {header.head}
650            {'  '}
651          </Text>
652          <Box flexDirection="row" flexGrow={1}>
653            {summary}
654          </Box>
655          {hint}
656        </Box>
657        {shown.map((entry, index) => (
658          <Box key={entry.sessionID} flexDirection="row" columnGap={1}>
659            <Box width={1} flexShrink={0}>
660              <Text color={COLOR[entry.light]}>{GLYPH[entry.light]}</Text>
661            </Box>
662            {/* Der Name ist der Knopf: „1“ im leeren Prompt (oder ctrl+x tab
663                und „1“; ein Klick nur im Vollbild-Terminal) öffnet den Tab. */}
664            <Box width={nameWidth + 3} flexShrink={0}>
665              {index < 9 ? (
666                <Button
667                  key={`open-${entry.sessionID}`}
668                  label={clip(entry.title, nameWidth)}
669                  hotkey={String(index + 1)}
670                  plain
671                  dimColor={entry.light === 'parked'}
672                  onPress={() => openChat($, entry)}
673                />
674              ) : (
675                <Text dimColor={entry.light === 'parked'} wrap="truncate-end">
676                  {'   '}
677                  {entry.title}
678                </Text>
679              )}
680            </Box>
681            {wide && (
682              <Box width={8} flexShrink={0}>
683                <Text dimColor>{entry.ref}</Text>
684              </Box>
685            )}
686            <Box flexGrow={1} flexShrink={1}>
687              <Text
688                color={entry.light === 'needsYou' ? 'error' : undefined}
689                dimColor={entry.light !== 'needsYou'}
690                italic={entry.light === 'parked'}
691                wrap="truncate-end"
692              >
693                {entry.status === 'awaitingInput' && entry.light !== 'needsYou' ? '⚑ ' : ''}
694                {say(entry)}
695              </Text>
696            </Box>
697            <Box width={ageWidth} flexShrink={0} justifyContent="flex-end">
698              <Text dimColor>{age(entry.updatedAt, t, !wide)}</Text>
699            </Box>
700          </Box>
701        ))}
702        {hidden > 0 && <Text dimColor>+{hidden} weitere</Text>}
703      </Box>
704    )
705  })
706}
707
hooks/tasks.tsx 246 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On, PluginOptions } from 'claude-code'
3
4import type { TaskItem, TaskStatus } from '../types'
5
6// Tasks im Chat: Claude Codes eigene Liste (ctrl+t) bleibt die einzige Liste im
7// Terminal. Die Mod hängt nur einen Zähler an die Hinweiszeile unter dem Prompt
8// und erklärt dem Modell `metadata.links`. Die volle Liste mit Links zeigt die
9// Notiz-Karte der App (`AgentChatTasksStore` liest dieselben Dateien).
10//
11// Bis 22.10.2026 stand hier eine eigene Liste (/t, Pane und Band über dem
12// Prompt). In App-Sessions (Main-Screen) zeichnet Claude den Bereich über dem
13// Prompt ständig neu; die hohe Liste hinterließ dabei Reste im Verlauf. Claudes
14// Liste hält sich klein und hat das Problem nicht.
15//
16// Wahrheit: Claude Codes Dateien `<config-dir>/tasks/<liste>/<id>.json`. Die Mod
17// liest sie nach jedem TaskCreate/TaskUpdate und alle 5 s (Teammates anderer
18// Prozesse schreiben ohne tool.call hier).
19//
20// Eigene Datei, von register.tsx nur eingebunden: Hooks und `$` folgt die
21// Engine über einen Import nur in Funktions-Deklarationen, und `session.start`
22// darf je Plugin nur einmal ohne Matcher stehen (dort ruft register.tsx
23// `startTasks`).
24
25const taskItems = atom({ plugin: 'whisperm8', key: 'taskItems' } as const, [] as TaskItem[])
26const taskDir = atom({ plugin: 'whisperm8', key: 'taskDir' } as const, '')
27
28/** Gefundene Ordner der Task-Listen je Session-ID (für Resume), höchstens 30. */
29const STORE_DIRS = 'taskDirs'
30const POLL_MS = 5_000
31
32const LINKS_HINT =
33  '\n\nLinks: when a task has something the user should open (the running app, a site, a design, a PR, a document), pass metadata.links as [{ "label": "App", "url": "http://localhost:3000" }] — label one or two words, http(s) or file URLs. The user sees them as clickable links under the task in the WhisperM8 app; put them on the task instead of repeating them in replies. With TaskUpdate the links key is replaced as a whole.'
34
35// Modul-Zustand: nur Steuerung, die Tasks stehen im Atom.
36let counterEnabled = true
37let pollStarted = false
38let refreshPending = false
39/** Name → mtime/Größe und gelesener Task: nur geänderte Dateien neu lesen. */
40let cache = new Map<string, { stamp: string; item: TaskItem | null }>()
41let cacheDir = ''
42
43// MARK: Lesen
44
45const STATUSES: readonly string[] = ['pending', 'in_progress', 'completed']
46
47/** Claude Codes Task-Datei, nur was Zähler und Ordnersuche brauchen. */
48export function parseTask(text: string): TaskItem | null {
49  try {
50    const raw = JSON.parse(text) as { id?: unknown; subject?: unknown; status?: unknown }
51    if (typeof raw.id !== 'string' || typeof raw.subject !== 'string') return null
52    if (typeof raw.status !== 'string' || !STATUSES.includes(raw.status)) return null
53    return { id: raw.id, subject: raw.subject, status: raw.status as TaskStatus }
54  } catch {
55    return null
56  }
57}
58
59/** Numerische IDs numerisch („2“ vor „10“), sonst alphabetisch. */
60export function byID(a: TaskItem, b: TaskItem) {
61  const x = Number(a.id)
62  const y = Number(b.id)
63  if (Number.isFinite(x) && Number.isFinite(y)) return x - y
64  return a.id.localeCompare(b.id)
65}
66
67/** Claude Codes Dateiname der Liste: Sonderzeichen werden zu „-“. */
68function sanitize(name: string) {
69  return name.replace(/[^a-zA-Z0-9_-]/g, '-')
70}
71
72async function tasksRoot($: EngineInterface) {
73  const configDir = (await $.env.get('CLAUDE_CONFIG_DIR')) || `${(await $.env.get('HOME')) ?? ''}/.claude`
74  return `${configDir.replace(/\/$/, '')}/tasks`
75}
76
77/**
78 * Ordner der Task-Liste. Claude Code nimmt CLAUDE_CODE_TASK_LIST_ID, sonst den
79 * Teamnamen der Session — der ist über die API nicht lesbar und folgt nicht
80 * immer der Session-ID (mit --session-id gestartet: ein anderer). Deshalb der
81 * Reihe nach: Umgebung, der beim ersten TaskCreate gefundene Ordner (Session
82 * bzw. $.store je Session-ID, für Resume), zuletzt die Vermutung
83 * `session-<erste 8 Zeichen>`.
84 */
85async function taskDirs($: EngineInterface): Promise<string[]> {
86  const root = await tasksRoot($)
87  const listID = await $.env.get('CLAUDE_CODE_TASK_LIST_ID')
88  if (listID) return [`${root}/${sanitize(listID)}`]
89  const sessionID = await $.session.id()
90  const learned = await read($, taskDir)
91  const stored = ((await $.store.get(STORE_DIRS).catch(() => undefined)) ?? {}) as Record<string, string>
92  return [learned, stored[sessionID] ?? '', `${root}/session-${sanitize(sessionID.slice(0, 8))}`, `${root}/${sanitize(sessionID)}`].filter(
93    (dir, index, all) => dir !== '' && all.indexOf(dir) === index,
94  )
95}
96
97/**
98 * Nach einem TaskCreate: den Ordner suchen, in dem `<id>.json` mit genau diesem
99 * Titel liegt, und ihn merken. Erst die Kandidaten, dann alle Listen — das
100 * einmalige Durchsuchen kostet ein Lesen je Liste, danach nichts mehr.
101 */
102async function learnDir($: EngineInterface, id: string, subject: string) {
103  const matches = async (dir: string) => {
104    const text = await $.fs.read(`${dir}/${sanitize(id)}.json`).catch(() => null)
105    return typeof text === 'string' && parseTask(text)?.subject === subject
106  }
107  if ((await $.env.get('CLAUDE_CODE_TASK_LIST_ID')) || (await read($, taskDir)) !== '') return
108  let found = ''
109  for (const dir of await taskDirs($)) {
110    if (await matches(dir)) {
111      found = dir
112      break
113    }
114  }
115  if (found === '') {
116    const root = await tasksRoot($)
117    const lists = (await $.fs.list(root).catch(() => [])).filter(entry => entry.kind === 'dir').slice(0, 400)
118    for (const entry of lists) {
119      if (await matches(`${root}/${entry.name}`)) {
120        found = `${root}/${entry.name}`
121        break
122      }
123    }
124  }
125  if (found === '') return
126  await update($, taskDir, () => found)
127  const sessionID = await $.session.id()
128  const stored = ((await $.store.get(STORE_DIRS).catch(() => undefined)) ?? {}) as Record<string, string>
129  const kept = Object.entries({ ...stored, [sessionID]: found }).slice(-30)
130  await $.store.set(STORE_DIRS, Object.fromEntries(kept)).catch(() => undefined)
131}
132
133async function refreshTasks($: EngineInterface) {
134  let dir = ''
135  let entries: Awaited<ReturnType<EngineInterface['fs']['list']>> = []
136  for (const candidate of await taskDirs($)) {
137    const listed = await $.fs.list(candidate).catch(() => null)
138    if (listed !== null) {
139      dir = candidate
140      entries = listed
141      break
142    }
143  }
144  if (dir !== cacheDir) {
145    cache = new Map()
146    cacheDir = dir
147  }
148  const next = new Map<string, { stamp: string; item: TaskItem | null }>()
149  let changed = false
150  for (const entry of entries) {
151    if (entry.kind !== 'file' || !entry.name.endsWith('.json') || entry.name.startsWith('.')) continue
152    const stamp = `${entry.mtimeMs}:${entry.size}`
153    const known = cache.get(entry.name)
154    if (known !== undefined && known.stamp === stamp) {
155      next.set(entry.name, known)
156      continue
157    }
158    const text = await $.fs.read(`${dir}/${entry.name}`).catch(() => null)
159    next.set(entry.name, { stamp, item: typeof text === 'string' ? parseTask(text) : null })
160    changed = true
161  }
162  if (!changed && next.size === cache.size) return
163  cache = next
164  const items = [...next.values()].flatMap(v => (v.item === null ? [] : [v.item])).sort(byID)
165  await update($, taskItems, () => items)
166}
167
168/** Mehrere Änderungen kurz hintereinander lösen EIN Lesen aus. */
169function scheduleRefresh($: EngineInterface) {
170  if (refreshPending) return
171  refreshPending = true
172  $.clock.after(150, () => {
173    refreshPending = false
174    void refreshTasks($).catch(() => undefined)
175  })
176}
177
178// MARK: Zähler
179
180export function counterText(items: readonly TaskItem[]): string {
181  const active = items.filter(t => t.status === 'in_progress').length
182  const open = items.filter(t => t.status === 'pending').length
183  if (active + open === 0) return ''
184  const parts: string[] = []
185  if (active > 0) parts.push(`${active} aktiv`)
186  if (open > 0) parts.push(`${open} offen`)
187  return parts.join(' · ')
188}
189
190// MARK: Hooks
191
192/** Aus session.start (register.tsx): erstes Lesen und Takt, nur mit Zähler. */
193export async function startTasks($: EngineInterface) {
194  if (!counterEnabled) return
195  // Lesen nicht awaiten: der Chatstart wartet nicht auf Dateien.
196  void refreshTasks($).catch(() => undefined)
197  if (!pollStarted) {
198    pollStarted = true
199    poll($)
200  }
201}
202
203/**
204 * 5-s-Takt als Kette aus `clock.after` (läuft einmal je Mod-Instanz; ein Reload
205 * bricht die Kette mit der alten Umgebung ab).
206 */
207function poll($: EngineInterface) {
208  $.clock.after(POLL_MS, () => {
209    void refreshTasks($)
210      .catch(() => undefined)
211      .finally(() => poll($))
212  })
213}
214
215export function registerTasks(on: On, options: PluginOptions) {
216  counterEnabled = options.taskzaehler !== false
217
218  // Das Modell erfährt, wie es Links an einen Task hängt (die Notiz-Karte der App zeigt sie klickbar).
219  on('tool.describe', { tool: ['TaskCreate', 'TaskUpdate'] }, async ($, e, next) => {
220    const described = await next(e)
221    return { ...described, description: `${described.description}${LINKS_HINT}` }
222  })
223
224  if (!counterEnabled) return
225
226  on('tool.call', { tool: ['TaskCreate', 'TaskUpdate'] }, async ($, e, next) => {
227    const ran = await next(e)
228    const created = 'result' in ran ? (ran.result as { task?: { id?: unknown; subject?: unknown } } | undefined)?.task : undefined
229    if (typeof created?.id === 'string' && typeof created.subject === 'string') {
230      await learnDir($, created.id, created.subject).catch(() => undefined)
231    }
232    scheduleRefresh($)
233    return ran
234  })
235
236  // Zähler am Ende der Hinweiszeile unter dem Prompt („3 aktiv · 2 offen“).
237  // Nicht $.ui.status: davor setzt Claude Code „⚠“, das las sich als Warnung.
238  // Eine Zeile mit fester Höhe: hinterlässt im Main-Screen keine Reste.
239  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
240    const text = counterText(await read($, taskItems))
241    if (text === '') return next(e)
242    const tail = `Tasks ${text}`
243    return next({ ...e, props: { ...e.props, tail: e.props.tail ? `${e.props.tail} · ${tail}` : tail } })
244  })
245}
246
types/index.d.ts 79 lines
1// Vertrag der Werte, die die Mods des Plugins `whisperm8` in $.state halten.
2// Nur Anzeige-Zustand: die Wahrheit des Boards liegt in der App
3// (`whisperm8 chats board`).
4
5export type BoardLight = 'needsYou' | 'running' | 'done' | 'parked'
6
7/** Ein Eintrag, wie `whisperm8 chats board --json` ihn liefert. */
8export type BoardMilestone = {
9  at: string
10  text: string
11}
12
13export type BoardEntry = {
14  ref: string
15  sessionID: string
16  title: string
17  project: string
18  light: BoardLight
19  /** Ziel: der Auftrag des Chats. */
20  mission: string
21  /** Eingriff: was der Chat vom User braucht (bei needsYou). */
22  needs: string
23  /** Arbeitspaket. Fehlt bei CLIs von vor dem Feld — dann steht es in `next`. */
24  work?: string
25  /** Früherer Name von `work`; die App schreibt beide gleich. */
26  next: string
27  /** Was den Chat aufhält, ohne dass der User gefragt ist. */
28  blocker?: string
29  /** Neueste zuerst. */
30  milestones?: BoardMilestone[]
31  updatedAt: string
32  /** Laufzeitstatus des Chats (working, awaitingInput, idle, stopped …). */
33  status: string
34  statusSince: string | null
35  otherOwners: string[]
36}
37
38export type BoardView = {
39  owner: string
40  ownerRef: string
41  isActive: boolean
42  cursor: string | null
43  entries: BoardEntry[]
44}
45
46// MARK: Tasks (hooks/tasks.tsx)
47// Nur für den Zähler: die Wahrheit liegt in Claude Codes Task-Dateien
48// (`<config-dir>/tasks/<liste>/<id>.json`).
49
50export type TaskStatus = 'pending' | 'in_progress' | 'completed'
51
52/** Ein Task, wie Claude Code ihn als `<id>.json` ablegt (nur die gelesenen Felder). */
53export type TaskItem = {
54  id: string
55  subject: string
56  status: TaskStatus
57}
58
59declare module 'claude-code' {
60  interface PluginState {
61    whisperm8: {
62      /** Letzter gelesener Stand des eigenen Boards; null = noch nie gelesen. */
63      board: BoardView | null
64      /** Band auf eine Zeile eingeklappt. */
65      folded: boolean
66      /** Uhr fürs Alter der Einträge, tickt im 30-s-Raster. */
67      now: number
68      /** Schon geweckte Zustände je Chat (`<sessionID>:<zustand>`), gegen Doppel-Wecken. */
69      woken: string[]
70      /** Callout-Modus der Session: Claude darf am Turn-Ende sprechen. */
71      callout: boolean
72      /** Tasks der laufenden Session, nach ID sortiert. */
73      taskItems: TaskItem[]
74      /** Ordner der Task-Liste, beim ersten TaskCreate gefunden; '' = noch unbekannt. */
75      taskDir: string
76    }
77  }
78}
79