Whisper but better

<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 & 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">
WhisperM8 has two halves that share one app.
screencapture -i) as context for AI post-processingwhisperm8 CLI — Transcribe audio/video files from the terminal (or let your AI agent do it — see CLI)A session manager for Claude Code and Codex CLI:
claude --bg agentsInstall:
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.
Control + Shift + Space.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.Cancel Recording: Click the X button in the overlay.
whisperm8The 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.
| Provider | Price | Speed | Link |
|---|---|---|---|
| Groq | Free (rate-limited) | Very Fast | Get API Key |
| OpenAI | ~$0.006/min | Fast | Get API Key |
For detailed troubleshooting, see docs/README.md.
Building from source, make targets, resets and releases are for contributors only — see docs/DEVELOPMENT.md. End users install and update exclusively via Homebrew.
Contributions are welcome! Please feel free to submit a Pull Request.
Built and open-sourced by 360WebManager — Your partner for web development and digital solutions.
MIT License — see LICENSE for details.
Made with ❤️ for the macOS community
hooks/register.tsx 707 lines1import { 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}
707hooks/tasks.tsx 246 lines1import { 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}
246types/index.d.ts 79 lines1// 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