/sessions zeigt alle laufenden Claude-Sessions mit Thema, Kontext und Cache-Stand; /pause lässt die warmen ihren Stand in die Datei schreiben, die ihre…

Mikes Mods für Claude Code, auf Deutsch. Entwickelt auf dem Mac mini, MIT-Lizenz.
| Mod | Version | Kurz |
|---|---|---|
verbrauch/ | 0.7.2 | Statuszeile + /verbrauch-Pane: 5-h-/7-Tage-Limits, Kontext, Cache-Sperre, Hintergrund-Agenten, Auto-Neustart (/auto) |
sessions/ | 0.4.1 | /sessions, /pause (sichern, neu, selbst, auto, abbrechen) |
clean-view hat ein eigenes öffentliches Repo: https://github.com/skaterino/claude-clean-view
In ~/.claude/settings.json:
"env": { "CLAUDE_CODE_PLUGIN_DIRS": "~/.claude/mods/verbrauch:~/.claude/mods/sessions:~/.claude/mods/clean-view" }
Unter Windows Pfade mit ; trennen. Installation auf Windows Schritt für Schritt: INSTALL-WINDOWS.md.
Nicht im geladenen Ordner entwickeln (jede Session lädt neu): Kopie bauen, claude plugin validate + claude plugin test, dann einspielen, hier committen und pushen.
Der Windows-Stand hat die Mac-Anpassungen (HOME vor USERPROFILE, liste.js mit procStart-Prüfung, geteilte Limits ab verbrauch 0.4.0) noch nicht. Dieses Repo ist die gemeinsame Quelle.
hooks/register.tsx 677 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Anchor, Overview, Restart, Session } from '../types'
5
6const PANE = 'sessions'
7// Im Abo läuft der Prompt-Cache eine Stunde nach der letzten Antwort ab; warm heisst hier: 5 Minuten Reserve.
8const TTL_MS = 60 * 60_000
9const WARM_MS = 55 * 60_000
10const EVERY_MS = 20_000
11// Kennwörter in der Nachricht an die anderen Sessions; die globale CLAUDE.md erklärt [PAUSE-SICHERN].
12const SAVE_MARK = '[PAUSE-SICHERN]'
13const RESTART_MARK = '[PAUSE-NEU]'
14const ASK_OTHERS =
15 'Mike macht Pause und hat /pause ausgelöst: Bitte jetzt den aktuellen Stand in die Datei schreiben, die deine Pickup-Phrase einliest ' +
16 '(🔑-Zeile in MEMORY.md; steht unten als Zieldatei, wenn der Mod sie kennt), sonst ins zuständige Memory, wie beim MEMORY-HEARTBEAT.'
17// Anfang jedes eigenen Sicherungs-Prompts: daran erkennt turn.start den Sicherungs-Turn.
18const SELF_MARK = 'Pause: Schreib jetzt'
19const ASK_SELF = `${SELF_MARK} den aktuellen Stand ins zuständige Memory, wie beim MEMORY-HEARTBEAT.`
20const askSelfInto = (a: Anchor): string =>
21 `${SELF_MARK} den aktuellen Stand in ${a.file}, die Datei, die „${a.phrase}“ einliest, wie beim MEMORY-HEARTBEAT.`
22const target = (a: Anchor): string => `Zieldatei (liest „${a.phrase}“ ein): ${a.file}`
23// /pause auto (Auto-Neustart aus dem verbrauch-Mod): eigene Marke, damit turn.start den Sicherungs-Turn bindet.
24// Die Session schreibt dazu genau eine Zeile Auto-Weiter mit der Kennung dieser Sicherung; der Mod prüft sie,
25// statt sich auf die Bitte zu verlassen. Die Kennung zeigt, dass die Zeile zu dieser Sicherung gehört, auch wenn
26// sie sonst Wort für Wort einer älteren gleicht (die bliebe dann unverändert und wäre keine Nachweis-Zeile).
27const AUTO_MARK = 'Pause (Auto-Neustart): Schreib jetzt'
28const askAutoInto = (a: Anchor, token: string): string =>
29 `${AUTO_MARK} den aktuellen Stand in ${a.file}, die Datei, die „${a.phrase}“ einliest, wie beim MEMORY-HEARTBEAT. ` +
30 'Kurz, nur das Neue (Memory-Arbeitsprozess in der globalen CLAUDE.md). Schreib zusätzlich genau eine Zeile ' +
31 `\`Auto-Weiter: ja — <nächster konkreter Schritt> [${token}]\` oder \`Auto-Weiter: nein — <Grund> [${token}]\`, ` +
32 `mit der Kennung [${token}] dieser Sicherung am Ende. Steht schon eine Auto-Weiter-Zeile in der Datei, ersetz sie ` +
33 'durch die neue, auch wenn sie sonst gleich bleibt, statt eine zweite anzuhängen. nein, wenn der nächste Schritt Mike ' +
34 'braucht: etwas zeigen oder abnehmen lassen, eine Entscheidung aus dem Plan, Geld, Rechtliches, ein Prod-Deploy, ' +
35 'oder wenn du ihm zuletzt eine Frage gestellt hast (dann die Frage mit in die Datei). Nichts nachfragen, nicht ' +
36 'zurückschreiben, danach nichts weiter tun.'
37// Was die frische Session nach dem Auto-Neustart hinter der Pickup-Phrase liest. Ohne '<': anchorOf überspringt solche Zeilen.
38const AUTO_GO =
39 'Auto-Neustart: Prüf den Stand wie üblich gegen git/Prod, dann mach den Schritt aus `Auto-Weiter: ja` und arbeite ' +
40 'danach im Plan weiter. Die Halt-Liste gilt: Braucht ein Schritt Mike, hör dort auf und frag ihn.'
41const AUTO_WAIT =
42 'Auto-Neustart: Stand einlesen, prüfen, kurz berichten (inkl. Grund aus `Auto-Weiter: nein`), dann warten.'
43const AUTO_UNCLEAR =
44 'Auto-Neustart: Stand einlesen, prüfen, kurz berichten, dann warten. Die Sicherung hatte keine eindeutige ' +
45 '`Auto-Weiter`-Zeile.'
46// Eine Zeile, die mit Auto-Weiter: beginnt, auch als Listenpunkt, Überschrift, Zitat, fett oder in Backticks
47// (`- **Auto-Weiter:** ja — …`, `**Auto-Weiter**: nein`). AUTO_LINE liest den Wert: ja oder nein, danach Leerraum,
48// Zeilenende oder — – - : . , ; (nach schliessendem Markup); die Kennung am Ende (` [a7f3]`) stört das nicht. So
49// zählen `ja|nein`, `ja/nein` und der zurückgeschriebene Auftrag (`Auto-Weiter: ja — <…> [a7f3]` oder …) nicht; eine
50// Zeile mit ja und nein als Wörtern auch nicht (AUTO_JA/NEIN), eine mit Platzhalter `<…>` aus dem Auftrag auch nicht.
51const AUTO_LABEL = /^(?:>\s*)?(?:#{1,6}\s+|[-*+]\s+|\d+[.)]\s+)?[*_`]*\s*Auto-Weiter[*_`]*\s*:/iu
52const AUTO_LINE =
53 /^(?:>\s*)?(?:#{1,6}\s+|[-*+]\s+|\d+[.)]\s+)?[*_`]*\s*Auto-Weiter[*_`]*\s*:\s*[*_`]*\s*(ja|nein)(?=[*_`]*(?:\s|$|[—–\-:.,;]))/iu
54// Nur die Platzhalter aus dem Auftrag (<nächster konkreter Schritt>, <Grund>, <…>); ein einzelnes < wie in
55// „Kontext < 200k“ oder JSX wie <Box> in einem echten Schritt zählt nicht.
56const AUTO_PLACEHOLDER = /<(?:…|\.{3}|nächster[^<>\n]{0,40}|Grund)>/iu
57const AUTO_JA = /(?<![\p{L}\p{N}])ja(?![\p{L}\p{N}])/iu
58const AUTO_NEIN = /(?<![\p{L}\p{N}])nein(?![\p{L}\p{N}])/iu
59// Kommt der Auftrag mitten in eine laufende Arbeit, darf das Sichern sie nicht abbrechen; wartete die Session
60// auf Mike, soll sie danach nicht von selbst loslegen.
61const RULES =
62 'Kurz halten, nur das Neue (Memory-Arbeitsprozess in der globalen CLAUDE.md). Hat dieser Auftrag dich mitten ' +
63 'in einer Arbeit unterbrochen, mach danach dort weiter; hast du auf Mike gewartet, tu danach nichts weiter. ' +
64 'Nichts nachfragen und nicht zurückschreiben.'
65// Was der Mod beim Empfänger dazuschreibt; er weiss, ob der Auftrag in einen laufenden Turn kam.
66const MID_TURN = 'Du warst mitten in einer Arbeit: nach dem Sichern dort weitermachen, falls noch etwas offen ist.'
67const IDLE = 'Du hast auf Mike gewartet: nach dem Sichern nichts weiter tun.'
68const USAGE =
69 'Aufruf: /pause (Stand sichern), /pause neu (sichern, danach frisch mit der Pickup-Phrase), ' +
70 '/pause selbst (nur diese Session sichern und frisch starten), /pause auto (wie selbst, die Session ' +
71 'arbeitet danach von selbst weiter, wenn sie Auto-Weiter: ja schreibt) oder /pause abbrechen (ein vorgemerkter ' +
72 'Neustart dieser Session entfällt)'
73
74const overview = atom({ plugin: 'sessions', key: 'overview' } as const, { at: 0, list: [], error: '' } as Overview)
75// Wem /pause die Sicherung aufgetragen hat, je Session-ID mit Zeitpunkt.
76const asked = atom({ plugin: 'sessions', key: 'asked' } as const, {} as Record<string, number>)
77// Nach dem laufenden Turn frisch starten: /clear, dann die Pickup-Phrase ins Feld (bei /pause auto abgeschickt);
78// nur wenn die Zieldatei in dieser Zeit von der Session geschrieben wurde. null heisst nichts tun.
79const restart = atom({ plugin: 'sessions', key: 'restart' } as const, null as Restart | null)
80
81const fmtTokens = (n: number): string =>
82 n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : n > 0 ? `${n}` : '–'
83
84const fmtAgo = (ms: number): string => {
85 const min = Math.max(0, Math.round(ms / 60_000))
86 if (min < 120) return `${min} Min.`
87 const h = Math.round(min / 60)
88 return h < 48 ? `${h} h` : `${Math.round(h / 24)} Tagen`
89}
90
91const fmtClock = (at: number): string => {
92 try {
93 return new Intl.DateTimeFormat('de-CH', { timeZone: 'Europe/Zurich', hour: '2-digit', minute: '2-digit' }).format(at)
94 } catch {
95 return new Date(at).toISOString().slice(11, 16)
96 }
97}
98
99const isWarm = (s: Session, now: number): boolean =>
100 s.status === 'busy' || (s.lastApiAt > 0 && now - s.lastApiAt < WARM_MS)
101
102const cacheText = (s: Session, now: number): string => {
103 if (s.status === 'busy') return 'arbeitet'
104 if (!s.lastApiAt) return 'noch keine Antwort'
105 const idle = now - s.lastApiAt
106 return idle < WARM_MS ? `warm, noch ${Math.max(1, Math.round((TTL_MS - idle) / 60_000))} Min.` : `❄ kalt seit ${fmtAgo(idle)}`
107}
108
109// Nach /pause: sichert noch, bis die Session wieder ruht und seither geantwortet hat.
110const pauseText = (s: Session, since: number | undefined): string =>
111 since === undefined ? '' : s.status === 'idle' && s.lastApiAt > since ? ' · ✓ gesichert' : ' · ⏳ sichert …'
112
113async function home($: EngineInterface): Promise<string> {
114 const dir = (await $.env.get('HOME')) || (await $.env.get('USERPROFILE'))
115 if (!dir) throw new Error('HOME ist nicht gesetzt')
116 return dir
117}
118
119// Liest die Sessions über liste.js; ein Fehler steht in der Übersicht statt falscher Daten.
120async function refresh($: EngineInterface): Promise<Overview> {
121 const at = await $.clock.now()
122 let next: Overview
123 try {
124 const run = await $.process.run(['node', `${await home($)}/.claude/mods/sessions/liste.js`], { timeoutMs: 20_000 })
125 next =
126 run.exitCode === 0
127 ? { at, list: JSON.parse(run.stdout) as Session[], error: '' }
128 : { at, list: [], error: (run.stderr || run.stdout).trim().slice(0, 300) || `liste.js endete mit ${run.exitCode}` }
129 } catch (err) {
130 next = { at, list: [], error: String(err).slice(0, 300) }
131 }
132 await update($, overview, () => next)
133 return next
134}
135
136const NONE: Anchor = { phrase: '', file: '' }
137// Nach so langer Zeit ohne begonnenen Sicherungs-Turn darf /pause selbst neu vormerken.
138// So lange vor dem Vormerken zählt ein /auto aus noch (Lücke zwischen Auslösen in verbrauch und Vormerken hier).
139const ABORT_SLACK_MS = 5_000
140const MIN_RE_ARM_MS = 60_000
141// Vergleichsform einer Phrase: "Web shop pickup" und "webshop pickup" sind dieselbe.
142const norm = (text: string): string => text.toLowerCase().replace(/[^\p{L}\p{N}]/gu, '')
143const baseName = (path: string): string => path.split(/[\\/]/).pop() ?? path
144const samePath = (a: string, b: string): boolean => a.replace(/\\/g, '/') === b.replace(/\\/g, '/')
145
146// MEMORY.md dieser Session: aus dem Kontext, sonst nach dem Muster der Projektordner.
147async function memoryIndex($: EngineInterface): Promise<string> {
148 try {
149 const usage = await $.session.usage({ breakdown: 'summary' })
150 const hit = usage.context.breakdown?.memoryFiles.find(f => /[\\/]memory[\\/]MEMORY\.md$/.test(f.path))
151 if (hit) return hit.path
152 } catch {
153 // Dann eben über den Projektordner.
154 }
155 const config = (await $.env.get('CLAUDE_CONFIG_DIR')) || `${await home($)}/.claude`
156 return `${config}/projects/${(await $.session.root()).replace(/[^A-Za-z0-9]/g, '-')}/memory/MEMORY.md`
157}
158
159// Pickup-Phrasen aus MEMORY.md: in einer 🔑-Zeile die Phrase gleich nach dem Schlüssel, dazu „(auch `…`)“.
160// Gehört eine Phrase zu zwei Dateien, ist sie zweideutig (null) und startet nichts neu.
161const anchorsIn = (index: string, dir: string): Map<string, Anchor | null> => {
162 const out = new Map<string, Anchor | null>()
163 for (const line of index.split('\n')) {
164 const link = /^\s*-\s*\[[^\]]*\]\(([^)\s]+\.md)\)/.exec(line)
165 if (!link?.[1] || link[1].includes('..') || !line.includes('🔑')) continue
166 const file = `${dir}/${link[1]}`
167 for (const m of line.matchAll(/🔑\s*(?:Magic-Word:\s*)?`([^`]+)`|\(auch\s+`([^`]+)`\)/g)) {
168 const phrase = (m[1] ?? m[2] ?? '').trim()
169 const key = norm(phrase)
170 if (!key) continue
171 const had = out.get(key)
172 out.set(key, had === undefined || had?.file === file ? { phrase, file } : null)
173 }
174 }
175 return out
176}
177
178// Womit diese Session begann und wohin sie deshalb sichert. Ohne Eintrag in MEMORY.md bleibt file leer;
179// ohne Pickup-Phrase auch phrase.
180async function anchorOf($: EngineInterface): Promise<Anchor> {
181 let anchors = new Map<string, Anchor | null>()
182 try {
183 const index = await memoryIndex($)
184 anchors = anchorsIn(await $.fs.read(index), index.replace(/[\\/]MEMORY\.md$/, ''))
185 } catch {
186 // Ohne MEMORY.md kennt der Mod keine Zieldatei.
187 }
188 const rows = await $.session.messages()
189 for (const row of rows.slice(0, 15)) {
190 if (row.role !== 'user' || row.text.includes('<')) continue
191 const text = norm(row.text.slice(0, 120))
192 // Der längste passende Schlüssel entscheidet; ist er zweideutig, gibt es keine Zieldatei.
193 let best: Anchor | null | undefined
194 let bestKey = ''
195 for (const [key, a] of anchors) {
196 if (key.length > bestKey.length && text.startsWith(key)) {
197 best = a
198 bestKey = key
199 }
200 }
201 if (best) return best
202 const hit = /^\s*(.{1,40}?\bpickup)\b/i.exec(row.text)
203 if (hit?.[1]) return { phrase: hit[1], file: '' }
204 }
205 return NONE
206}
207
208async function readText($: EngineInterface, file: string): Promise<string> {
209 try {
210 return await $.fs.read(file)
211 } catch {
212 return ''
213 }
214}
215
216// Kennung einer Sicherung für /pause auto: 4 Hex-Zeichen, zufällig (crypto gehört zu den Globals des Hooks-Moduls).
217const newToken = (): string => {
218 try {
219 return Array.from(crypto.getRandomValues(new Uint8Array(2)), b => b.toString(16).padStart(2, '0')).join('')
220 } catch {
221 return Math.floor(Math.random() * 0x10000).toString(16).padStart(4, '0')
222 }
223}
224
225// Eine Kennung, die noch nirgends in der Zieldatei steht: sonst gälte eine stehengebliebene alte Zeile mit
226// zufällig gleicher Kennung als neu geschrieben.
227async function freshToken($: EngineInterface, file: string): Promise<string> {
228 const text = (await readText($, file)).toLowerCase()
229 for (let i = 0; i < 20; i++) {
230 const token = newToken()
231 if (!text.includes(`[${token}]`)) return token
232 }
233 return newToken()
234}
235
236// Merkt den Neustart vor; der nächste Turn ist der Sicherungs-Turn. token nur bei /pause auto: die Kennung, die
237// in der Auto-Weiter-Zeile stehen muss.
238async function arm($: EngineInterface, a: Anchor, token?: string): Promise<void> {
239 const at = await $.clock.now()
240 await update($, restart, () => ({ ...a, turn: '', at, proof: [], ...(token !== undefined ? { auto: true as const, token } : {}) }))
241}
242
243// Der vorgemerkte Neustart, falls einer läuft (auch gegen einen alten Wert aus einer früheren Version).
244async function armed($: EngineInterface): Promise<Restart | undefined> {
245 const r = await read($, restart)
246 return r && typeof r === 'object' && typeof r.file === 'string' && r.file && typeof r.turn === 'string' && Array.isArray(r.proof)
247 ? r
248 : undefined
249}
250
251const linesOf = (text: string): string[] => text.split('\n').map(l => l.trim()).filter(Boolean)
252
253// Was die Sicherung für den Auto-Neustart sagt: genau eine Auto-Weiter-Zeile in der Datei, die trägt die Kennung
254// dieser Sicherung ([token], gross oder klein) und sagt eindeutig ja oder nein. Ob sie zu den Nachweis-Zeilen gehört,
255// zählt nicht: die Kennung kennt nur dieser Sicherungs-Auftrag. Sonst gilt 'unklar' (die Session wartet): keine,
256// eine alte oder eine anders geschriebene Kennung, mehrere Zeilen, ein Platzhalter `<…>`, ja und nein zugleich.
257const autoDecision = (text: string, token: string | undefined): 'ja' | 'nein' | 'unklar' => {
258 const hits = linesOf(text).filter(l => AUTO_LABEL.test(l))
259 const line = hits[0]
260 if (typeof token !== 'string' || !token || hits.length !== 1 || line === undefined) return 'unklar'
261 if (!line.toLowerCase().includes(`[${token.toLowerCase()}]`) || AUTO_PLACEHOLDER.test(line)) return 'unklar'
262 const value = AUTO_LINE.exec(line)?.[1]?.toLowerCase()
263 if (!value || (AUTO_JA.test(line) && AUTO_NEIN.test(line))) return 'unklar'
264 return value === 'ja' ? 'ja' : 'nein'
265}
266
267// Wann der verbrauch-Mod den Auto-Neustart dieser Session zuletzt abbrach (/auto aus nach dem Auslösen); 0 nie.
268// Eine Datei statt /pause abbrechen allein: $.command.run wartet, bis die Session ruht, also bis nach dem
269// Sicherungs-Turn, und dann liefe das /clear schon.
270async function autoStoppedAt($: EngineInterface): Promise<number> {
271 try {
272 const data = JSON.parse(await readText($, `${await home($)}/.claude/mod-daten/sessions/${await $.session.id()}.abbruch`)) as {
273 at?: unknown
274 } | null
275 return typeof data?.at === 'number' ? data.at : 0
276 } catch {
277 return 0
278 }
279}
280
281// Vor einem Werkzeug, das die Zieldatei schreiben könnte: ihr Inhalt, um danach zu sehen, was es änderte.
282async function snapshot($: EngineInterface, hits: (r: Restart) => boolean): Promise<string | undefined> {
283 try {
284 const r = await armed($)
285 return r && hits(r) ? await readText($, r.file) : undefined
286 } catch {
287 return undefined
288 }
289}
290
291// Nach dem Werkzeug: die Zeilen, die es neu in die Zieldatei brachte, sind der Nachweis. Zeilen, die es
292// entfernte, fallen aus dem Nachweis (die Session hat ihren eigenen Eintrag überarbeitet).
293async function credit($: EngineInterface, before: string | undefined, ok: boolean): Promise<void> {
294 if (before === undefined || !ok) return
295 try {
296 const r = await armed($)
297 if (!r) return
298 const after = linesOf(await readText($, r.file))
299 const had = new Set(linesOf(before))
300 const now = new Set(after)
301 const added = after.filter(l => !had.has(l))
302 if (!added.length && after.length === had.size) return
303 await update($, restart, x =>
304 x ? { ...x, proof: [...new Set([...x.proof.filter(l => now.has(l)), ...added])].slice(-50) } : x,
305 )
306 } catch {
307 // Ohne Nachweis startet die Session nur nicht neu.
308 }
309}
310
311// Nennt ein Bash-Befehl die Datei als eigenes Wort (nicht project_abc.md für abc.md)?
312const namesFile = (command: string, file: string): boolean => {
313 const name = baseName(file).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
314 return new RegExp(`(^|[\\s/'"=(])${name}(?=$|[\\s'");|&>])`).test(command)
315}
316
317// Marke für liste.js: diese Session hat den Mod und kann sich nach /pause neu selbst neu starten. Dazu ihre
318// Pickup-Phrase und Zieldatei, sobald bekannt; der verbrauch-Mod zeigt sie an.
319async function mark($: EngineInterface, anchor: Anchor = NONE): Promise<void> {
320 try {
321 const file = `${await home($)}/.claude/mod-daten/sessions/${await $.session.id()}.json`
322 const id = await $.session.id()
323 await $.fs.write(file, JSON.stringify({ at: await $.clock.now(), pickup: anchor.phrase, file: anchor.file }))
324 if (id !== markedId) {
325 // Neue Session-ID (/clear): ihr Stand beginnt von vorn.
326 markedId = id
327 tries = 0
328 }
329 marked = anchor.file
330 markedPhrase = anchor.phrase
331 } catch {
332 // Ohne Marke zeigt die Übersicht nur "ohne Mod".
333 }
334}
335
336// Die Zieldatei steht erst nach dem ersten Prompt fest: nach den ersten Haupt-Turns nachtragen, bis sie bekannt
337// ist. Die Phrase steht am Anfang; wer sie nach drei Turns nicht hat, bekommt keine mehr.
338async function markAnchor($: EngineInterface): Promise<void> {
339 // Nach einem /clear von Hand läuft der Prozess unter neuer ID weiter, ohne session.start: dann neu anfangen.
340 if ((await $.session.id()) !== markedId) await mark($)
341 if (marked || tries >= 3) return
342 // Ein leerer Verlauf (frische Session vor dem ersten Prompt) verbraucht keinen Versuch.
343 const rows = await $.session.messages()
344 if (!rows.some(r => r.role === 'user' && !r.text.includes('<'))) return
345 tries++
346 const anchor = await anchorOf($).catch(() => NONE)
347 // Auch eine Phrase ohne 🔑-Zeile kommt in die Marke, damit das verbrauch-Fenster den Grund nennen kann.
348 if (anchor.file || (anchor.phrase && anchor.phrase !== markedPhrase)) await mark($, anchor)
349}
350
351// Modulzustand: ein Reload setzt ihn zurück.
352let active = false
353// Zieldatei, die zuletzt in der Marke stand; '' noch keine.
354let marked = ''
355let markedPhrase = ''
356// Für welche Session-ID die Marke gilt und wie oft die Zieldatei dort schon gesucht wurde.
357let markedId = ''
358let tries = 0
359let ticker: { cancel: () => void } | undefined
360
361function watch($: EngineInterface): void {
362 if (ticker) return
363 ticker = $.clock.every(EVERY_MS, () => {
364 void refresh($)
365 })
366}
367
368async function show($: EngineInterface): Promise<void> {
369 await $.ui.open({ id: PANE, title: 'Sessions' })
370 watch($)
371}
372
373// /pause selbst (auch der Knopf im verbrauch-Fenster): nur diese Session sichert und startet frisch, mit
374// demselben Nachweis wie /pause neu; keine andere Session wird angeschrieben. auto (/pause auto, der
375// Auto-Neustart des verbrauch-Mods): Auftrag mit Auto-Weiter-Zeile, danach wird die Phrase abgeschickt.
376async function saveSelf($: EngineInterface, auto = false): Promise<{ text: string }> {
377 const anchor = await anchorOf($).catch(() => NONE)
378 if (!anchor.file) {
379 return {
380 text: anchor.phrase
381 ? `Kein Neustart: „${anchor.phrase}“ steht nicht als 🔑 in MEMORY.md, es gibt keine Pickup-Datei.`
382 : 'Kein Neustart: Diese Session begann ohne Pickup-Phrase, es gibt keine Pickup-Datei.',
383 }
384 }
385 // Eine laufende oder frische Vormerkung sperrt; eine liegengebliebene (Prompt verworfen, nie gestartet) nicht.
386 const pending = await armed($)
387 if (pending && (pending.turn || (await $.clock.now()) - pending.at < MIN_RE_ARM_MS)) {
388 return { text: 'Läuft schon: diese Session sichert gerade und startet danach neu.' }
389 }
390 const token = auto ? await freshToken($, anchor.file) : undefined
391 await arm($, anchor, token)
392 const self = await $.session.id()
393 const now = await $.clock.now()
394 await update($, asked, m => ({ ...m, [self]: now }))
395 const ask = token !== undefined ? askAutoInto(anchor, token) : `${askSelfInto(anchor)} ${RULES}\n${IDLE}`
396 $.clock.after(200, () => {
397 void $.prompt.submit({ text: ask, asUser: true }).catch(() => {
398 void update($, restart, () => null).catch(() => undefined)
399 $.ui.toast('Diese Session konnte ihre Sicherung nicht starten: bitte selbst „Stand ins Memory“ sagen.', { timeoutMs: 10_000 })
400 })
401 })
402 return {
403 text: auto
404 ? `Auto-Neustart: Diese Session sichert ihren Stand in ${baseName(anchor.file)} und startet danach frisch mit „${anchor.phrase}“, sobald sie die Datei geschrieben hat; mit Auto-Weiter: ja arbeitet sie dann von selbst weiter.`
405 : `Diese Session sichert ihren Stand in ${baseName(anchor.file)} und startet danach frisch mit „${anchor.phrase}“, sobald sie die Datei geschrieben hat.`,
406 }
407}
408
409export const register: Register = on => {
410 on('session.start', async ($, e, next) => {
411 const started = await next(e)
412 active = e.isInteractive
413 await $.command.register({
414 name: 'sessions',
415 description: 'Übersicht aller laufenden Claude-Sessions: Thema, Kontext, Cache warm oder kalt',
416 })
417 await $.command.register({
418 name: 'pause',
419 description: 'Vor der Pause: warme Sessions schreiben ihren Stand ins Memory; „neu“ startet sie danach frisch',
420 argumentHint: '[neu|selbst|auto|abbrechen]',
421 })
422 marked = ''
423 markedPhrase = ''
424 markedId = ''
425 tries = 0
426 if (active) {
427 await mark($)
428 // Nach --resume oder einem Reload steht die Pickup-Phrase schon im Verlauf.
429 await markAnchor($)
430 }
431 return started
432 })
433
434 on('command.run', { command: 'sessions' }, async $ => {
435 if ((await $.ui.panes()).some(pane => pane.id === PANE)) {
436 await $.ui.close({ id: PANE })
437 return { text: 'Übersicht geschlossen.' }
438 }
439 await refresh($)
440 await show($)
441 return { text: 'Übersicht der Sessions offen.' }
442 })
443
444 on('command.run', { command: 'pause' }, async ($, e) => {
445 const arg = e.args.trim().toLowerCase()
446 if (arg !== '' && arg !== 'neu' && arg !== 'selbst' && arg !== 'auto' && arg !== 'abbrechen') return { text: USAGE }
447 if (arg === 'abbrechen') {
448 // Auch der verbrauch-Mod nach /auto aus. Was schon gesichert wird, bleibt gesichert; nur der Neustart entfällt.
449 if (!(await armed($))) return { text: 'Kein Neustart vorgemerkt.' }
450 await update($, restart, () => null)
451 return { text: 'Neustart abgebrochen: diese Session startet nach dem Sichern nicht neu, der Kontext bleibt.' }
452 }
453 if (arg === 'selbst') return saveSelf($)
454 if (arg === 'auto') return saveSelf($, true)
455 const fresh = arg === 'neu'
456 const { list, error } = await refresh($)
457 if (error) return { text: `Sessions nicht lesbar: ${error}` }
458 const now = await $.clock.now()
459 const self = await $.session.id()
460 const others = list.filter(s => s.sessionId !== self)
461 // Kalte Sessions nicht wecken: dort kostet schon das Sichern den vollen Neuschreib des Kontexts.
462 const warm = others.filter(s => isWarm(s, now))
463 const cold = others.filter(s => !isWarm(s, now))
464 const message = `${ASK_OTHERS} ${SAVE_MARK}${fresh ? ` ${RESTART_MARK}` : ''}\n${RULES}`
465 const reached: Session[] = []
466 const failed: string[] = []
467 for (const s of warm) {
468 try {
469 const sent = await $.tool.call({ tool: 'SendMessage', to: s.name, summary: 'Pause: Stand ins Memory', message })
470 if (sent.deny) failed.push(`${s.name} (${sent.deny})`)
471 else if (sent.isError) failed.push(`${s.name} (${String(sent.text ?? 'Fehler').slice(0, 120)})`)
472 else reached.push(s)
473 } catch (err) {
474 failed.push(`${s.name} (${String(err).slice(0, 120)})`)
475 }
476 }
477 const anchor = await anchorOf($).catch(() => NONE)
478 if (fresh && anchor.file) await arm($, anchor)
479 await update($, asked, m => ({ ...m, [self]: now, ...Object.fromEntries(reached.map(s => [s.sessionId, now])) }))
480 // Ein Befehl darf keinen Prompt abschicken, solange er läuft: erst danach, über einen kurzen Timer.
481 $.clock.after(200, () => {
482 // Die eigene Session ruht hier immer (/pause wartet, bis sie frei ist): danach nichts weiter tun, auch
483 // nicht das wieder aufnehmen, was Mike eben mit Esc gestoppt hat.
484 void $.prompt.submit({ text: `${anchor.file ? askSelfInto(anchor) : ASK_SELF} ${RULES}\n${IDLE}`, asUser: true }).catch(() => {
485 $.ui.toast('Diese Session konnte ihre Sicherung nicht starten: bitte selbst „Stand ins Memory“ sagen.', { timeoutMs: 10_000 })
486 })
487 })
488 await show($)
489
490 const into = anchor.file ? ` in ${baseName(anchor.file)}` : ''
491 const lines = [
492 fresh && anchor.file
493 ? `Diese Session sichert ihren Stand${into} und startet danach frisch mit „${anchor.phrase}“, sobald sie die Datei geschrieben hat.`
494 : fresh && anchor.phrase
495 ? `Diese Session sichert ihren Stand; „${anchor.phrase}“ steht nicht als 🔑 in MEMORY.md, darum startet sie nicht neu.`
496 : fresh
497 ? 'Diese Session sichert ihren Stand; ohne Pickup-Phrase am Anfang startet sie nicht neu.'
498 : `Diese Session sichert ihren Stand${into}.`,
499 ]
500 if (reached.length) lines.push(`Ebenfalls gesichert: ${reached.map(s => s.name).join(', ')}.`)
501 if (fresh) {
502 const manual = reached.filter(s => !s.hasMod)
503 if (manual.length) lines.push(`Ohne Mod, sichern nur und starten nicht neu: ${manual.map(s => s.name).join(', ')}.`)
504 }
505 if (cold.length) lines.push(`Übersprungen, Cache schon kalt: ${cold.map(s => s.name).join(', ')}.`)
506 if (failed.length) lines.push(`Nicht erreicht: ${failed.join('; ')}.`)
507 return { text: lines.join('\n') }
508 })
509
510 // Empfänger von /pause: bekommt seine Zieldatei genannt; bei „neu“ startet er nach dem Turn frisch,
511 // aber nur, wenn er sie geschrieben hat.
512 on('prompt.submit', async ($, e, next) => {
513 // Mike schreibt (Tastatur oder Telefon), während ein Auto-Neustart vorgemerkt ist: er ist da, der Neustart
514 // entfällt. Auch mitten im Sicherungs-Turn; der sichert dann nur.
515 if (active && (e.origin.kind === 'composer' || e.origin.kind === 'bridge')) {
516 try {
517 if ((await armed($))?.auto) {
518 await update($, restart, () => null)
519 $.ui.toast('Auto-Neustart abgebrochen: du hast geschrieben.', { timeoutMs: 10_000 })
520 }
521 } catch {
522 // Dann läuft der Neustart wie vorgemerkt.
523 }
524 }
525 const fromPeer = e.origin.kind === 'peer' || e.origin.kind === 'peer-send-message'
526 if (!active || !fromPeer || !e.text.includes(SAVE_MARK)) return next(e)
527 const anchor = await anchorOf($).catch(() => NONE)
528 const how = e.turnId ? MID_TURN : IDLE
529 if (!anchor.file) return next({ ...e, text: `${e.text}\n${how}` })
530 if (e.text.includes(RESTART_MARK)) {
531 // Mitten in einen laufenden Turn zugestellt: der arbeitet nach dem Sichern weiter, ein Neustart
532 // an seinem Ende verlöre das. Dann nur sichern.
533 if (e.turnId) $.ui.toast('/pause neu kam in einen laufenden Turn: diese Session sichert nur und startet nicht neu.', { timeoutMs: 10_000 })
534 else await arm($, anchor)
535 }
536 return next({ ...e, text: `${e.text}\n${target(anchor)}\n${how}` })
537 })
538
539 on('turn.start', async ($, e, next) => {
540 const started = await next(e)
541 try {
542 const r = await armed($)
543 // Nur der Turn des Sicherungs-Prompts, nicht ein vorher eingereihter anderer.
544 const isSave = r?.auto ? e.text.includes(AUTO_MARK) : e.text.includes(SELF_MARK) || e.text.includes(SAVE_MARK)
545 if (r && !r.turn && isSave) {
546 await update($, restart, x => (x ? { ...x, turn: e.turnId } : x))
547 }
548 } catch {
549 // Ohne Bindung bleibt der Neustart aus.
550 }
551 return started
552 })
553
554 // Wer schreibt die Zieldatei? Nur was die Session selbst schreibt, zählt für den Neustart.
555 // Bei allen drei zählt nur, was der Aufruf selbst an der Datei änderte: Lesen (cat, tail, grep) ändert nichts.
556 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
557 const before = await snapshot($, r => samePath(e.file_path, r.file))
558 const ran = await next(e)
559 await credit($, before, ran.deny === undefined && ran.isError !== true)
560 return ran
561 })
562 on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
563 const before = await snapshot($, r => samePath(e.file_path, r.file))
564 const ran = await next(e)
565 await credit($, before, ran.deny === undefined && ran.isError !== true)
566 return ran
567 })
568 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
569 const before = await snapshot($, r => namesFile(e.command, r.file))
570 const ran = await next(e)
571 await credit($, before, ran.deny === undefined && ran.isError !== true)
572 return ran
573 })
574
575 on('turn.complete', async ($, e, next) => {
576 const done = await next(e)
577 if (!active || e.agentId) return done
578 await markAnchor($)
579 const r = await armed($)
580 // Nur das Ende des Sicherungs-Turns zählt; ein früher endender Turn lässt den Auftrag stehen.
581 if (!r || r.turn !== e.turnId) return done
582 await update($, restart, () => null)
583 if (e.reason !== 'answer') {
584 $.ui.toast('Kein Neustart nach /pause: Der Sicherungs-Turn endete nicht sauber.', { timeoutMs: 10_000 })
585 return done
586 }
587 // Nur neu starten, wenn der Stand nachweislich in der Datei steht, die die Pickup-Phrase einliest.
588 const now = await readText($, r.file)
589 if (!r.proof.length || !r.proof.every(p => now.includes(p))) {
590 $.ui.toast(`Kein Neustart nach /pause: ${baseName(r.file)} wurde nicht geschrieben, der Kontext bleibt.`, {
591 timeoutMs: 10_000,
592 })
593 return done
594 }
595 // /auto aus kam, nachdem dieser Auto-Neustart vorgemerkt war (oder knapp davor, solange verbrauch ihn schon
596 // ausgelöst, sessions ihn aber noch nicht vorgemerkt hatte): kein /clear.
597 if (r.auto && (await autoStoppedAt($)) >= r.at - ABORT_SLACK_MS) {
598 $.ui.toast('Auto-Neustart abgebrochen (/auto aus): gesichert, aber kein Neustart, der Kontext bleibt.', { timeoutMs: 10_000 })
599 return done
600 }
601 const phrase = r.phrase
602 // Auto-Neustart: die Phrase wird abgeschickt, mit dem Auftrag je nach Auto-Weiter-Zeile. Auch bei nein oder
603 // unklar startet die Session frisch: kommt Mike zurück, trifft er auf einen kleinen Kontext.
604 const decision = r.auto ? autoDecision(now, r.token) : undefined
605 if (decision === 'unklar') {
606 $.ui.toast(`Auto-Neustart: ${baseName(r.file)} hat keine eindeutige Auto-Weiter-Zeile mit der Kennung dieser Sicherung, die frische Session wartet.`, {
607 timeoutMs: 10_000,
608 })
609 }
610 // Nicht abwarten: /clear läuft erst, wenn die Session ruht, und dieser Turn endet gerade.
611 void (async () => {
612 await $.command.run({ command: 'clear' })
613 // Nur eine leere Marke: welche Phrase gilt, zeigt erst der erste echte Turn (Mike kann sie noch ändern).
614 await mark($)
615 if (decision === undefined) await $.prompt.fill({ text: phrase })
616 else {
617 const then = decision === 'ja' ? AUTO_GO : decision === 'nein' ? AUTO_WAIT : AUTO_UNCLEAR
618 // /clear ist hier schon fertig (kein Befehl läuft mehr): der Prompt startet gleich einen Turn.
619 await $.prompt.submit({ text: `${phrase}\n${then}`, asUser: true })
620 }
621 })().catch(() => {
622 $.ui.toast('Neustart nach /pause hat nicht geklappt: bitte /clear von Hand.', { timeoutMs: 10_000 })
623 })
624 return done
625 })
626
627 // /clear von Hand: der Prozess läuft unter neuer ID weiter, ohne session.start. Kurz danach die Marke für
628 // die neue ID schreiben, damit Übersicht und verbrauch-Fenster die Session gleich wieder kennen.
629 on('session.end', async ($, e, next) => {
630 const done = await next(e)
631 if (active && e.reason === 'clear') {
632 $.clock.after(300, () => {
633 void markAnchor($).catch(() => undefined)
634 })
635 }
636 return done
637 })
638
639 on('ui.close', async ($, e, next) => {
640 if (e.id === PANE) {
641 ticker?.cancel()
642 ticker = undefined
643 }
644 return next(e)
645 })
646
647 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
648 const { Box, Text } = $.ui.resolve(e)
649 const { at, list, error } = await read($, overview)
650 const pending = await read($, asked)
651 const self = await $.session.id()
652 if (error) return <Text color="error">{`Sessions nicht lesbar: ${error}`}</Text>
653 if (!at) return <Text dimColor>Lese die Sessions …</Text>
654 const warm = list.filter(s => isWarm(s, at)).length
655 return (
656 <Box flexDirection="column">
657 <Text dimColor>{`${list.length} Sessions · ${warm} warm · Stand ${fmtClock(at)}`}</Text>
658 {list.map(s => {
659 const color = s.status === 'busy' ? 'success' : isWarm(s, at) ? 'warning' : 'subtle'
660 return (
661 <Box flexDirection="column">
662 <Text> </Text>
663 <Text bold={s.sessionId === self} color={color} wrap="truncate">
664 {`${s.name}${s.sessionId === self ? ' (diese)' : ''} · ${cacheText(s, at)}`}
665 </Text>
666 <Text wrap="truncate">{` ${s.topic || '–'}`}</Text>
667 <Text dimColor wrap="truncate">
668 {` ${fmtTokens(s.context)} · ${s.version}${s.hasMod ? '' : ' · ohne Mod'}${pauseText(s, pending[s.sessionId])}`}
669 </Text>
670 </Box>
671 )
672 })}
673 </Box>
674 )
675 })
676}
677types/index.d.ts 73 lines1/** Eine laufende Claude-Session dieses Rechners, wie liste.js sie liest. */
2export type Session = {
3 pid: number
4 sessionId: string
5 /** Der Name, unter dem andere Sessions sie erreichen (SendMessage). */
6 name: string
7 /** `interactive` oder `bg`. */
8 kind: string
9 /** `busy` oder `idle`. */
10 status: string
11 version: string
12 cwd: string
13 startedAt: number
14 /** Zeitpunkt der letzten Antwort im Haupt-Loop (ms), die Cache-Uhr; 0 unbekannt. */
15 lastApiAt: number
16 /** Kontext bei der letzten Antwort in Tokens; 0 unbekannt. */
17 context: number
18 /** Pickup-Phrase oder Anfang des ersten eigenen Prompts. */
19 topic: string
20 /** Pickup-Phrase, '' wenn keine. */
21 pickup: string
22 /** Läuft der Mod dort (Marke beim Start gesetzt)? Nur dann kann sie sich nach /pause neu selbst neu starten. */
23 hasMod: boolean
24}
25
26/** Wohin eine Session ihren Stand sichert: die Datei, die ihre Pickup-Phrase laut MEMORY.md einliest. */
27export type Anchor = {
28 /** Die Pickup-Phrase, wie MEMORY.md sie schreibt; '' wenn keine. */
29 phrase: string
30 /** Absoluter Pfad der Datei; '' wenn die Phrase in MEMORY.md nicht vorkommt. */
31 file: string
32}
33
34/** Vorgemerkter Neustart nach /pause neu. */
35export type Restart = Anchor & {
36 /** Der Sicherungs-Turn (turn.start nach dem Auftrag); '' solange er nicht begonnen hat. */
37 turn: string
38 /** Wann vorgemerkt (ms): eine Vormerkung ohne begonnenen Sicherungs-Turn verfällt für /pause selbst nach 60 s. */
39 at: number
40 /** Was die Session zuletzt selbst in die Zieldatei schrieb (Write, Edit, Bash); muss am Ende noch drinstehen. */
41 proof: string[]
42 /**
43 * /pause auto (Auto-Neustart des verbrauch-Mods): nach dem Nachweis zählt die Auto-Weiter-Zeile, und die Phrase
44 * wird abgeschickt statt nur ins Feld gelegt. Fehlt bei /pause neu und /pause selbst.
45 */
46 auto?: true
47 /**
48 * Nur bei /pause auto: die Kennung dieser Sicherung (4 Hex-Zeichen). Der Auftrag nennt sie; nur eine Auto-Weiter-Zeile
49 * mit `[Kennung]` zählt als ja oder nein, auch wenn sie sonst Wort für Wort einer älteren gleicht.
50 */
51 token?: string
52}
53
54export type Overview = {
55 /** Zeitpunkt des Einlesens (ms); 0 noch nie. */
56 at: number
57 list: Session[]
58 /** Warum nichts gelesen werden konnte; '' wenn alles gut ging. */
59 error: string
60}
61
62declare module 'claude-code' {
63 interface PluginState {
64 sessions: {
65 overview: Overview
66 /** Wem /pause die Sicherung aufgetragen hat: Session-ID → Zeitpunkt (ms). */
67 asked: Record<string, number>
68 /** Neustart nach dem laufenden Turn, sofern die Zieldatei geschrieben wurde; null heisst kein Neustart. */
69 restart: Restart | null
70 }
71 }
72}
73