Animated pixel mascot Clawd above the prompt: reacts to Claude's work, waiting times, idle time, time of day, the mouse and a mood that builds up over the…

An animated pixel mascot ("Clawd") sits to the right above the prompt. It reacts to the time of day, Claude's work, waiting times, idle phases, your typing, the mouse, and a mood that builds up over the session.
Texts are English by default; set
languagetodefor German.
Tested with Claude Code v2.1.295 (terminal and desktop app) · Plugin version 0.7.1
Clawd runs on his own. What he does depends on what is happening:
| Situation | What he does (examples) |
|---|---|
| Claude reads, writes, uses the shell (own animations for commit/push and tests/checks), the web, or subagents. Each running subagent (background ones too) gets a small helper in a lighter Claude color standing next to him (from 7 on, in a second row). They mostly work quietly side by side, think, report and talk things over; a wave or fist bump is rare. When a subagent finishes, its helper hands over the result (a note, a stack, a folder, an envelope, or a present) or they high-five | magnifying glass, laptop (types slowly to fast), terminal and log windows, floppy disk, upload cloud, clipboard, test tube, globe |
| A tool runs longer than 10 s or 60 s | taps his foot, clock, hourglass, sits or lies down |
Claude compacts the conversation (/compact or automatically) | squashes a paper stack into a tied package or crumples sheets into a box |
| A skill starts | briefly opens a book or grabs a wrench from a toolbox |
| With sidekick installed: it checks your message, holds it back with a question, or starts a new chat with a handoff | holds the magnifying glass to the input; holds up a stop sign; writes a letter and sends it off, then opens it in the new chat and waves |
| Claude is waiting for you (question, permission) | waves with a question mark, holds up a sign, points at the input, asks with a speech bubble; if the question is open for more than 45 s, he knocks on the input |
| Turn finished or aborted | jumps for joy or shrugs; every 5 successes in a row a trophy or medal; after a long turn (5 min or more) a big "phew" or a finish flag |
| The context window is getting full (70 % and again at 85 %, shown once at the end of the turn) | a box of paper that won't close, or a wobbly paper stack: time for /compact or a new chat |
| You are typing | reads along, takes notes, rubs his hands |
| Idle | pastimes by time of day: whistling and a little dance in the morning, juggling, soap bubbles, a flower at midday, calmer in the evening; at night a tea light, counting sheep, stargazing, then sleep |
| Birthday (setting) or New Year's Eve | cake with candles, party hat; fireworks, sparkler |
| Usage limit reached (5-hour or weekly) | 5 h: turns the hourglass, dozes with an alarm clock, spins a top, is annoyed or sad (can't keep working); weekly: sits on a suitcase, goes fishing, tends a potted plant, paints, knits a scarf, builds a sandcastle. After the reset: a jump for joy or a confetti cannon (always plays to the end) |
| Night (default 23:00 to 6:00) | yawns, nods off, sleeps with a nightcap |
| You come back after at least 2 h (first input, or the desktop session becomes visible again), at any time of day | gets up or rubs his eyes, then stretches, has a coffee, brushes his teeth, does jumping jacks or waters a flower |
| Claude switches between tools within seconds | at most two animations in a row are cut short; the third one stays at least about 8 s, so he does not flip back and forth; a question for you ("waiting for you") is exempt and usually shows within about 2–4 s; only if two questions come within about 8 s while he is switching quickly can the second take up to about 8 s |
| Mood | Piling errors make him irritable (facepalm, stomping, steam); successes in a row put him in a good mood; long stretches of work make him tired. Everything wears off again. |
| Input | Effect |
|---|---|
/clawd or /clawd status | on/off, mood, tiredness, annoyance; in the desktop app also measurements since the last call (draws per minute, animation length and frame changes, size, compute and drawing time, how often it redrew because the band layout changed or the session was shown again, and how many events needed no redraw) |
/clawd on / /clawd off | show or hide him (persists) |
/clawd list | all animations by group |
/clawd demo <name> | play one animation, e.g. /clawd demo type_laptop |
/clawd nap | sends him to sleep |
/clawd boop | pokes him |
/clawd help or /clawd ? | a table of all commands, the mouse controls, what is on or off right now and your settings with their values (drawn in the terminal and the desktop app, plain text elsewhere) |
/clawd flicker | desktop app only: a 20-second test in two parts (number top left): Clawd is redrawn every 2 s, first as an image (as in normal operation), then in a frame as up to 0.6.5, for comparison; afterwards he continues normally |
| Mouse (terminal): click | giggles; many clicks in a row make him annoyed, then offended |
| Mouse (terminal): drag an arm | the arm follows briefly and snaps back |
| Mouse (terminal): hold the body for ~0.7 s, then drag | he hangs from the pointer, falls when you let go, and walks back |
Runs in: the terminal (half-block pixels, with mouse) and the desktop app's Code tab (as an animated image, without mouse). In claude -p, the Agent SDK, VS Code and mobile he draws nothing.
/config → clawd-buddy:
| Key | Title in /config | Meaning | Default |
|---|---|---|---|
nightStart | Night starts (hour) | Hour (0 to 23) from which he gets tired and falls asleep when idle | 23 |
nightEnd | Night ends (hour) | Hour (0 to 23) until which it counts as night; if it is before the start, the night spans midnight | 6 |
idleSeconds | Idle time before pastimes (seconds) | Seconds without activity before he starts a pastime (10 to 600) | 45 |
reducedMotion | Reduced motion | Only the base pose and calm variants, no pastimes | off |
side | Position in the band | right or left above the prompt | right |
birthday | Birthday (DD.MM.) | Day (DD.MM.) on which he celebrates with you; empty = off. New Year's Eve is always on | 05.01. |
language | Language / Sprache | Language of the mod's texts: en English, de German | en |
Texts are English by default; set language to de for German (/config → clawd-buddy → Language / Sprache). This covers everything the mod writes: the output of /clawd (status, help, list, demo, nap, errors), the animation names and the image's alt text. Clawd himself shows no text. /clawd demo finds animations by their English and German names in both languages. Commands and arguments are English either way.
claude plugin validate shows:
hooks: session.start, session.measure, turn.start, turn.complete, tool.call, tool.check, session.compact, skill.prompt, prompt.edit, ui.message, command.run{command=clawd}, ui.render{component=CommandOutput, props has {command=clawd}}, ui.render{component=AbovePrompt}
calls: $.command.register, $.store.get, $.store.set, $.ui.invalidate, $.ui.resolve, $.clock.now, $.clock.every, $.ui.log, $.state.get
state reads: sidekick.buddy
Apart from drawing the /clawd help table (see below), all event hooks only observe and pass everything through unchanged. No file system, no processes, no network, no model calls.
In plain language:
$.command.register: registers the /clawd command.$.store.get / $.store.set: stores only on/off, the annoyance counter, and when you were last around (one timestamp, written at most every 5 min, for the welcome after a break). The plugin's own storage.$.ui.invalidate: redraws the band when something changes (turn, tool, typing at most once per second; in the desktop app typing at most every 3 s, plus once before each animation runs out, usually every 15 to 30 s; 2 to 3 times a minute when idle).$.ui.resolve: fetches the drawing components (Box, Client, Svg).ui.render on CommandOutput (only /clawd): draws the answer of /clawd help as a table in place of the text. It reads only the reference mark in its own command output; every other output of /clawd stays plain text.$.clock.now: time of day for day/night, waiting times, mood, and the break before the welcome.session.measure: reads only the percentage and reset time of the 5-hour and weekly limits (for the limit animations) and the fill percentage of the context window (for the "context almost full" hint); never the conversation itself.$.clock.every: a watcher (every 250 ms, no drawing of its own) only in the desktop app, while it draws the band and Clawd is on; it asks for the next animation shortly before the current one ends, or shortly before new facts would change what is shown. In the terminal the client ticks on its own.$.ui.log: error messages to the debug log.session.compact: notices only that the main conversation is being compacted and when it ends. Never reads the conversation.skill.prompt: notices only that a skill starts. Never reads the skill's text.$.state.get (sidekick.buddy): reads what the sidekick mod is doing right now (check, question, new chat), a kind and a timestamp, no texts. Without sidekick it stays empty. sidekick does not need to be installed.From tool results he reads only the "error" (isError) and "denied" (deny) flags, never contents or texts.
Add the marketplace once, then install the mod:
claude plugin marketplace add FynnXland/fynn-mods
claude plugin install clawd-buddy@fynn-mods
Inside a session the same works with /plugin marketplace add FynnXland/fynn-mods and /plugin install clawd-buddy@fynn-mods. The mod loads in the next session, or after /reload-plugins.
Check: /plugin shows … mod active · clawd-buddy.
Update: claude plugin update clawd-buddy@fynn-mods, or turn on auto-update for fynn-mods under Marketplaces in /plugin.
Remove: disable it under Installed in /plugin, or run claude plugin uninstall clawd-buddy@fynn-mods.
Try it for one session without installing (from a clone of the repo):
claude --plugin-dir <path-to-clone>/mods/clawd-buddy
To just hide him without uninstalling, use /clawd off.
isInteractive). Pastimes with many different frames get shorter animations, down to about 5 s. The band is redrawn only when an event (turn, tool, question, typing) changes what he shows, and shortly before the animation runs out; an event that changes nothing in the current animation does not redraw the band at all. If the band is redrawn for another mod, the same SVG is passed on unchanged, so the image is not restarted; a new animation is computed only shortly before something actually changes. /clawd status shows how often that happened. Up to 0.6.5 the SVG ran in a script-less frame (isInteractive), which went blank for a moment on every new animation, so the figure blinked; an image keeps showing the old picture until the new one is ready. Every redraw of the band makes the app also re-request the rows other mods hook (for example your own messages with sidekick), which made their hover bar flicker in 0.4.1 at about 5 redraws per second. While Claude works, tool calls still redraw the band now and then. No mouse (clicking, dragging) in the desktop app; /clawd boop|demo|nap work. The app draws the dark area behind the band itself and it cannot be hidden, so he does not sit quite flush on the input. In very narrow windows the 400 px wide image may be cut off.prompt.edit only reports a person as the source. Dictated text counts like typing ("reads along"). Dedicated recording animations will follow once Claude Code offers a signal for it.The figure is a fan homage to Clawd, the Claude Code mascot. The pixels are drawn by hand. Laptop typing motion after a reference GIF by Fynn Hansen. This is an unofficial project and is not affiliated with Anthropic.
hooks/register.ts 774 lines1// clawd-buddy: Hooks-Modul. Beobachtet nur: jeder Event-Hook gibt per `next(e)` unverändert weiter, nichts wird blockiert oder verändert.
2// Ausnahme seit 0.7.0: Der CommandOutput-Hook zeichnet die eigene Ausgabe von `/clawd help` als Tabelle (docs/HELP-SPEC.md).
3//
4// Aufgabe: aus Events *Fakten* sammeln (wann begann der Turn, welches Tool läuft seit wann, ob eine Frage offen ist, wann zuletzt
5// getippt wurde, wie der letzte Turn endete) und sie als `props` an das Client-Modul `./buddy.ts` reichen. Die Stimmung leitet der Client
6// daraus auf seiner eigenen Uhr ab (mood.ts); `$.ui.invalidate` läuft darum nur bei echten Ereignissen, nie im Bildtakt (Desktop: desk.ts).
7// Zeichnet ins Band über dem Prompt (docs/raw/en/interface.md:207-213) und lässt fremden Inhalt stehen (`await next(e)` als Kind).
8import type { EngineInterface, On, Timer } from 'claude-code'
9import { joinBand, nameOf, splitBand } from './band.ts'
10import { ALL_CLIPS } from './library.ts'
11import { AWAY_MS, NO_FACTS, NO_STRAIN, SETBACK, STREAK_STEP, TYPING_MS, addHit, ctxLevel, deriveTemper, shellKind, sidekickValue, strainTurnEnd, strainTurnStart, toolKind } from './mood.ts'
12import type { Strain } from './mood.ts'
13import { createDesk, DESK_TICK, stillSource } from './desk.ts'
14import { T, clipLabel, langOf, num as fmt } from './i18n.ts'
15import { ACCENT, SUBCOMMANDS, clawdHelp, isHelp } from './clawdhelp.ts'
16import { helpMarkdown, helpTree } from './help.ts'
17import type { HelpData } from './help.ts'
18import type { Lang } from './i18n.ts'
19import { H, W } from './stage.ts'
20import type { Desk } from './desk.ts'
21import type { Facts, ToolKind } from './mood.ts'
22
23type Stored = { enabled?: boolean; annoy?: number; seenAt?: number }
24
25// /clawd help (docs/HELP-SPEC.md §3): Schnappschuss beim Aufruf unter einer Kennung `#…`; der CommandOutput-Hook zeichnet ihn.
26// Höchstens 10 Einträge, der älteste fällt heraus. Status = Stand beim Aufruf, die Zeichnung schreibt sich nicht um.
27const helps = new Map<string, HelpData>()
28let helpNo = 0
29
30// Flüchtiger Zustand dieses Moduls (wird aus Events neu abgeleitet; Reload setzt zurück)
31let enabled = true
32let annoy = 0
33let napN = 0
34let boopN = 0
35let demo = ''
36let demoN = 0
37const seed = 20260
38const running = new Map<string, { kind: ToolKind; since: number }>()
39let facts: Facts = { ...NO_FACTS }
40let strain: Strain = { ...NO_STRAIN } // Laune: Rückschläge, Erfolge, Arbeitszeit am Stück (mood.ts)
41let turnHadError = false
42const agentSeen = new Map<string, number>() // laufende Subagenten: agentId → letzte Aktivität (ms)
43let askTool = ''
44let ctxWarned = 0 // zuletzt gemeldete Stufe des Kontextfensters (mood.ts → CTX_WARN)
45let ctxPending = false // neue Stufe erreicht, wird am Turn-Ende gezeigt
46let typingPerf = 0 // performance.now() des letzten Tastendrucks (synchron lesbar, ohne `$`-Aufruf pro Taste)
47let typingInvalidatedPerf = -1e9
48// Rückkehr nach langer Pause (Begrüßung): letzte Anwesenheit (ms, auch über Sitzungen hinweg gespeichert) und Drosseln
49let lastSeen = 0
50let seenSaved = 0
51let seenPerf = -1e9
52
53// Desktop-App: Dort lädt der Client-Rahmen nicht; die Engine läuft im Hooks-Modul (desk.ts). Jede Zeichnung liefert die nächsten
54// Sekunden als ein Svg mit SMIL-Animation, das der Desktop selbst abspielt. Neu gezeichnet wird nur bei Ereignissen und kurz bevor die
55// Animation endet (Fynn, 2026-10-06: Jede Neuzeichnung des Bands lässt den Desktop auch die von sidekick gehookten eigenen Nachrichten
56// neu anfordern; bei ~5 Bildwechseln/s flackerten deren Hover-Leiste und Hinterlegung. Vorher, 2026-10-05: 13/s ließen Knöpfe im Band flackern.)
57// Der Wächter (`$.clock.every`, liefert einen Timer mit `cancel()`, types:12280) läuft nur, solange die Desktop-App das Band zeichnet und
58// der Buddy an ist: `/clawd off` bricht ihn ab, und zeichnet die App auf die Bitte hin 2 s lang nicht, bricht er sich selbst ab; das
59// nächste Zeichnen startet ihn neu. Gezählt wird in Wächter-Runden, nicht in Echtzeit (im Test-Kit läuft nur die Uhr der Mods-API).
60const DESK_SCALE = 4 // CSS-Pixel je Figurpixel: Figur 68 × 40 px, Bühne 400 × 56 px
61const DESK_CHECK = 250 // ms je Wächter-Runde
62const DESK_LEAD = 1000 // so lange vor dem Ende der Animation um die nächste bitten
63const DESK_IDLE_CHECKS = 8 // ~2 s ohne Antwort auf die Bitte: Wächter beenden
64let desk: Desk | null = null
65let deskTimer: Timer | null = null
66let deskChecks = 0 // Wächter-Runden seit der letzten Zeichnung
67let deskPlanMs = 0 // Länge der zuletzt gezeichneten Animation
68let deskAsked = 0 // Wächter-Runden, seit um eine neue Zeichnung gebeten wurde (0 = keine Bitte offen)
69// Eine abgelehnte Periode beendet `$.clock.every` still (types:3390-3391), `deskTimer` bliebe dann gesetzt und der Wächter käme nie
70// wieder. Darum zählt jede Runde `deskBeats`; sieht ein Zeichnen DESK_IDLE_CHECKS Runden lang (~2 s Uhrzeit) keine neue, startet es ihn neu
71// (wie limit-bars). Verglichen wird von Zeichnen zu Zeichnen, nicht gegen eine Sollzahl: Runden laufen nacheinander und hinken der Uhr nach.
72let deskBeats = 0
73let deskBeatsSeen = 0 // Stand von deskBeats beim letzten Zeichnen, das eine neue Runde sah
74let deskBeatsSeenAt = 0 // Uhrzeit dieses Zeichnens
75// Flackern (Fynn, 2026-10-06: „Es darf nicht flackern“): Ein neues `source` lud bis 0.6.5 den Rahmen neu, die Figur blinkte dabei kurz.
76// Das Band wird auch für andere Mods neu gezeichnet (limit-bars, sidekick, quick-replies); Ereignisse, die an der laufenden Animation
77// nichts ändern, bitten seit 0.6.9 gar nicht erst darum (factsChanged). Zeichnet die App trotzdem, geht dasselbe Svg noch einmal hinaus; neu gerechnet wird erst kurz bevor die neuen Fakten etwas anderes
78// zeigen (desk.divergence) oder die Animation endet. Eingriffe (`/clawd demo|nap|boop|on`) erzwingen eine neue Zeichnung.
79// Bild statt Rahmen (0.6.6): Bis 0.6.5 lief das Svg mit `isInteractive` in einem Rahmen, der bei jedem neuen `source` neu lud und dabei kurz
80// leer war. `/clawd flicker` zeigte (Fynn, 2026-10-07: blinkt in allen drei Abschnitten), dass die App beim Wechsel auch einen unveränderten
81// Rahmen neu lädt; die Staffelübergabe mit zwei Rahmen (0.6.3) war wirkungslos. Jetzt ein einziges Svg als Bild (ohne `isInteractive`,
82// types:11956-11957): Beim Tausch bleibt das alte Bild stehen, bis das neue fertig ist; SMIL läuft auch im Bild (Abnahme 2026-10-07, desk.ts).
83let deskSvg = '' // zuletzt gezeichnetes Svg
84let deskDrawnAt = 0 // Uhrzeit der letzten echten Zeichnung
85let deskForce = false // Eingriff: sofort neu
86let deskFresh = false // Sitzung wieder angezeigt oder eingeschaltet: sofort neu (eigene Zählung in `/clawd status`)
87// Flacker-Test (`/clawd flicker`): Beginn (Uhrzeit), -2 = beginnt beim nächsten Zeichnen, -1 = aus. Zwei Abschnitte je 10 s, alle 2 s ein
88// neues `source` mit gleichem Bild; der Wächter bittet alle 0,5 s ums Zeichnen. 1 = als Bild (wie im Betrieb seit 0.6.6), 2 = im Rahmen
89// (`isInteractive`, wie bis 0.6.5) zum Vergleich. Ein Punkt neben der Nummer blinkt per SMIL: In 1 muss er blinken (SMIL läuft im Bild),
90// Clawd selbst darf es nicht.
91const DIAG_MS = 20_000
92const DIAG_STEP = 2000
93let deskDiag = -1
94let deskDiagRounds = 0 // Wächter-Runden im Test; zeichnet die App nicht mehr (verdeckt), endet er danach von selbst
95// Springen (Fynn, 2026-10-06: „springt von jetzt auf gleich zu einer ganz anderen Sache“): Baute der Desktop den Rahmen trotz gleichem `source` neu
96// auf (bis 0.6.5; ob ein neu eingehängtes Bild SMIL ebenso von vorn beginnt, ist [UNKLAR], im Browser tut es das), beginnt die Animation wieder bei ihrem Anfang, also Sekunden zurück, und bei der nächsten echten Zeichnung springt sie nach vorn. Das passiert
97// sicher, wenn sich der Aufbau des Bands ändert (eine Ebene von quick-replies oder sidekick kommt oder geht, andere Breite), und vermutlich auch nach
98// dem Wiederanzeigen der Sitzung [UNKLAR]. Deshalb: bei geändertem Aufbau immer neu zeichnen, und ein Svg höchstens DESK_REUSE_MS lang weitergeben;
99// ein unerkannter Neustart springt so höchstens um diese Zeit zurück.
100const DESK_REUSE_MS = 5000
101let deskShape = ''
102// Messung für `/clawd status` (Fynn, 2026-10-05: Clawd wirkt bei mehreren Agenten verzögert). Zählt seit dem letzten `/clawd status`,
103// mit performance.now() (synchron, kein `$`-Aufruf): Zeichnungen, Länge und Bildwechsel der Animationen, Rechen- und Zeichendauer.
104// Dazu (0.6.5): wie oft wegen eines geänderten Aufbaus des Bands bzw. nach Wiederanzeigen oder Einschalten neu gezeichnet wurde.
105type DeskMeter = {
106 since: number; draws: number; reused: number; planSecs: number; changes: number; chars: number; calcSum: number; calcMax: number; drawSum: number; drawMax: number; othersSum: number
107 freshShape: number; freshShown: number; skipped: number
108}
109const newMeter = (): DeskMeter => ({
110 since: performance.now(), draws: 0, reused: 0, planSecs: 0, changes: 0, chars: 0, calcSum: 0, calcMax: 0, drawSum: 0, drawMax: 0, othersSum: 0,
111 freshShape: 0, freshShown: 0, skipped: 0,
112})
113let meter = newMeter()
114function meterText(lang: Lang, m: DeskMeter, at: number): string {
115 const s = (at - m.since) / 1000
116 if (m.draws + m.reused === 0) return T[lang].deskNone
117 const avg = (sum: number) => fmt(lang, m.draws ? sum / m.draws : 0)
118 const avgAll = (sum: number) => fmt(lang, sum / (m.draws + m.reused))
119 return T[lang].desk({
120 secs: s.toFixed(0), draws: m.draws, reused: m.reused, perMin: fmt(lang, s > 0 ? (m.draws * 60) / s : 0), planSecs: avg(m.planSecs), changes: avg(m.changes),
121 kb: fmt(lang, m.draws ? m.chars / m.draws / 1000 : 0), calcAvg: avg(m.calcSum), calcMax: fmt(lang, m.calcMax), drawAvg: avgAll(m.drawSum),
122 drawMax: fmt(lang, m.drawMax), others: avgAll(m.othersSum),
123 }) + T[lang].deskFresh({ shape: m.freshShape, shown: m.freshShown, skipped: m.skipped })
124}
125
126/** Fakten für die Desktop-Engine zur Uhrzeit `now`: wie gespeichert, dazu der letzte Tastendruck auf diese Uhr umgerechnet. */
127function deskFacts(now: number): Facts {
128 const f: Facts = { ...facts }
129 if (typingPerf > 0) f.typingAt = now - (performance.now() - typingPerf)
130 return f
131}
132
133const num = (v: unknown, d: number): number => (typeof v === 'number' && Number.isFinite(v) ? v : d)
134
135function latestTool(): Facts['tool'] {
136 let best: { kind: ToolKind; since: number } | null = null
137 for (const t of running.values()) if (!best || t.since >= best.since) best = t
138 return best
139}
140
141/**
142 * Neue Fakten: um eine Neuzeichnung bitten. Im Desktop nur, wenn die gezeigte Animation sie vor ihrem Ende anders zeigen müsste
143 * (desk.divergence, wie beim Zeichnen); sonst rückt nur die Bitte des Wächters vor. Grund (Fynn, 2026-10-07): Jede Neuzeichnung des Bands
144 * lässt die Desktop-App auch die Zeilen neu anfordern, die sidekick an deinen Nachrichten hookt, und deren Hover-Leiste flackert (SPEC 0.4.2).
145 * Bis 0.6.8 zeichnete jedes Ereignis neu, auch wenn das Svg gleich blieb; bei viel Arbeit (mehrere Agenten) flackerte es so immer öfter.
146 * Ohne Uhrzeit (`now` fehlt, oder eine Ersatzzeit vor der letzten Zeichnung) oder im Terminal wie bisher: divergence liefe sonst von dieser
147 * Zeit an Takt für Takt bis zum Ende der Animation, bei 0 praktisch endlos (Review 0.6.9, S1).
148 */
149function factsChanged($: EngineInterface, now?: number) {
150 if (now !== undefined && now >= deskDrawnAt && desk && deskTimer && deskSvg !== '' && !deskForce && !deskFresh && deskAsked === 0 && deskDiag === -1) {
151 const div = desk.divergence(now, deskFacts(now), strain)
152 const left = desk.remaining(now)
153 if (div > DESK_LEAD && left > DESK_LEAD) {
154 deskPlanMs = Math.min(deskPlanMs, now - deskDrawnAt + Math.min(div, left))
155 meter.skipped += 1
156 return
157 }
158 }
159 $.ui.invalidate('ui.render')
160}
161
162// Top-Level-Funktion: `$` darf nur an solche weitergereicht werden (docs/raw/en/create.md:317)
163async function save($: EngineInterface) {
164 const prev = ((await $.store.get('buddy')) ?? {}) as Stored
165 await $.store.set('buddy', { ...prev, enabled, annoy })
166}
167
168/**
169 * Fynn ist da (tippt bzw. die Desktop-Sitzung wird wieder angezeigt). War er länger als AWAY_MS weg, begrüßt Clawd ihn mit einer
170 * Aufsteh-Animation (`facts.backAt`). Gespeichert wird nur der Zeitpunkt, höchstens alle 5 Minuten.
171 */
172async function noteSeen($: EngineInterface, now: number) {
173 if (lastSeen > 0 && now - lastSeen >= AWAY_MS) {
174 facts = { ...facts, backAt: now }
175 factsChanged($, now)
176 }
177 lastSeen = Math.max(lastSeen, now)
178 if (now - seenSaved < 300_000) return
179 seenSaved = now
180 const prev = ((await $.store.get('buddy')) ?? {}) as Stored
181 await $.store.set('buddy', { ...prev, seenAt: now })
182}
183
184export function register(on: On, options: Readonly<Record<string, string | number | boolean | readonly string[]>>) {
185 const nightStart = num(options.nightStart, 23)
186 const nightEnd = num(options.nightEnd, 6)
187 const idleSeconds = num(options.idleSeconds, 45)
188 const reduced = options.reducedMotion === true
189 const flip = options.side === 'left'
190 const birthday = typeof options.birthday === 'string' ? options.birthday : ''
191 const lang = langOf(options.language)
192 const tx = T[lang]
193
194 on('session.start', async ($, e, next) => {
195 try {
196 const s = ((await $.store.get('buddy')) ?? {}) as Stored
197 if (typeof s.enabled === 'boolean') enabled = s.enabled
198 annoy = num(s.annoy, 0)
199 // Zuletzt gesehen (auch in einer anderen Sitzung): eine neue Sitzung am nächsten Morgen begrüßt dich so ebenfalls
200 if (!lastSeen) lastSeen = num(s.seenAt, 0)
201 } catch (err) {
202 $.ui.log(`clawd-buddy: store not read: ${String(err)}`, { to: 'debug' })
203 }
204 try {
205 await $.command.register({ name: 'clawd', description: tx.description, argumentHint: 'on | off | list | demo <animation> | nap | boop | status | flicker | help' })
206 } catch (err) {
207 $.ui.log(`/clawd not registered: ${String(err)}`, { to: 'debug' })
208 }
209 return next(e)
210 })
211
212 // ---- Nutzungslimits (nur Prozent und Reset-Zeit, types:10566): ausgeschöpftes Limit → eigene Animationen
213 on('session.measure', async ($, e, next) => {
214 try {
215 const pick = (kind: string) => e.rateLimits.find((r) => r.kind === kind)
216 const five = pick('five_hour')
217 const week = pick('seven_day')
218 const ms = (iso?: string) => (iso ? Date.parse(iso) || undefined : undefined)
219 const limits = { five: five?.percentUsed, fiveReset: ms(five?.resetsAt), week: week?.percentUsed, weekReset: ms(week?.resetsAt) }
220 if (JSON.stringify(limits) !== JSON.stringify(facts.limits ?? {})) {
221 facts = { ...facts, limits }
222 factsChanged($, await $.clock.now().catch(() => undefined))
223 }
224 } catch (err) {
225 $.ui.log(`clawd-buddy: limits not read: ${String(err)}`, { to: 'debug' })
226 }
227 // Kontextfenster: nur die Prozentzahl (types:10771). Eine neue Stufe zeigt er am Turn-Ende; fällt sie (Komprimieren), ist sie wieder scharf.
228 try {
229 const pct = e.context?.percent
230 if (typeof pct === 'number' && Number.isFinite(pct)) {
231 const lvl = ctxLevel(pct)
232 if (lvl > ctxWarned) ctxPending = true
233 ctxWarned = lvl
234 if (ctxPending && !facts.turnActive) {
235 const at = await $.clock.now()
236 ctxPending = false // erst nach dem Lesen der Uhr: wirft sie, bleibt die Warnung für das Turn-Ende stehen
237 facts = { ...facts, ctxAt: at }
238 factsChanged($, at)
239 }
240 }
241 } catch (err) {
242 $.ui.log(`clawd-buddy: context not read: ${String(err)}`, { to: 'debug' })
243 }
244 return next(e)
245 })
246
247 // ---- Fakten aus den Events
248 on('turn.start', async ($, e, next) => {
249 facts = { ...facts, turnActive: true, endedKind: '', lastTool: undefined }
250 let now: number | undefined
251 try {
252 now = await $.clock.now()
253 if (!strain.turnSince) strain = strainTurnStart(strain, now)
254 turnHadError = false
255 } catch {
256 // Laune ist Beiwerk: ohne Uhr bleibt sie, wie sie ist
257 }
258 factsChanged($, now)
259 return next(e)
260 })
261
262 on('turn.complete', async ($, e, next) => {
263 // Ein Subagent-Lauf trägt `agentId`: dann ist dieser Subagent fertig (zählt nicht mehr), die Hauptstimmung bleibt
264 if (e.agentId) {
265 if (agentSeen.delete(e.agentId)) {
266 let doneAt = facts.agentDoneAt ?? 0
267 let clock = false
268 try {
269 doneAt = await $.clock.now()
270 clock = true
271 } catch (err) {
272 $.ui.log(`clawd-buddy: clock not read: ${String(err)}`, { to: 'debug' })
273 }
274 facts = { ...facts, agents: [...agentSeen.values()], agentDoneAt: doneAt }
275 factsChanged($, clock ? doneAt : undefined)
276 }
277 } else {
278 // Wirft die Uhr, bleibt der Turn trotzdem nicht "aktiv" hängen (letzte bekannte Zeit als Ersatz)
279 let now = facts.endedAt
280 try {
281 now = await $.clock.now()
282 } catch (err) {
283 $.ui.log(`clawd-buddy: clock not read: ${String(err)}`, { to: 'debug' })
284 }
285 running.clear()
286 askTool = ''
287 const turnMs = strain.turnSince ? Math.max(0, now - strain.turnSince) : 0
288 facts = { ...facts, turnActive: false, tool: null, ask: false, endedAt: now, endedKind: e.reason === 'answer' ? 'done' : 'oops', turnMs }
289 if (ctxPending) {
290 ctxPending = false
291 facts = { ...facts, ctxAt: now }
292 }
293 strain = strainTurnEnd(strain, now, e.reason === 'answer', !turnHadError)
294 // Erfolgsserie erreicht eine Marke (5, 10, 15 …): Pokal/Medaille statt des normalen Abschlusses
295 if (strain.streak > 0 && strain.streak % STREAK_STEP === 0) facts = { ...facts, streakAt: now }
296 factsChanged($, now)
297 }
298 return next(e)
299 })
300
301 on('tool.call', async ($, e, next) => {
302 const id = e.tool_use_id
303 // Subagenten-Tools (agentId) ändern die Hauptstimmung nicht; eine offene Rückfrage eines Subagenten räumen wir aber mit auf
304 if (e.agentId) {
305 // Subagent (auch Hintergrund, Worktree, Workflow, Teammate) ist aktiv: zählt für die Stimmung "Subagenten" bzw. "Schwarm"
306 try {
307 const isNew = !agentSeen.has(e.agentId)
308 const at = await $.clock.now()
309 agentSeen.set(e.agentId, at)
310 facts = { ...facts, agents: [...agentSeen.values()] }
311 if (isNew) factsChanged($, at)
312 } catch (err) {
313 $.ui.log(`clawd-buddy: clock not read: ${String(err)}`, { to: 'debug' })
314 }
315 try {
316 return await next(e)
317 } finally {
318 if (askTool === id) {
319 askTool = ''
320 facts = { ...facts, ask: false }
321 factsChanged($)
322 }
323 }
324 }
325 const isQuestion = e.tool === 'AskUserQuestion'
326 // Beobachtend: wirft die Uhr, geht der Tool-Aufruf trotzdem durch, nur ohne Stimmung (Review 2, K8)
327 let now: number
328 try {
329 now = await $.clock.now()
330 } catch (err) {
331 $.ui.log(`clawd-buddy: clock not read: ${String(err)}`, { to: 'debug' })
332 return next(e)
333 }
334 // Shell-Befehle genauer: Commit/Push bzw. Tests/Checks (nur Mustervergleich, der Befehl wird nicht gespeichert)
335 const base = toolKind(e.tool)
336 const kind = base === 'shell' ? shellKind((e as { command?: unknown }).command) : base
337 running.set(id, { kind, since: now })
338 facts = { ...facts, tool: latestTool(), ask: isQuestion || facts.ask, askSince: isQuestion && !facts.ask ? now : facts.askSince }
339 factsChanged($, now)
340 let res: Awaited<ReturnType<typeof next>> | undefined
341 try {
342 res = await next(e)
343 return res
344 } finally {
345 // Laune: nur die Markierung zählt (Fehler bzw. abgelehnt), der Inhalt des Ergebnisses wird nicht gelesen
346 if (res && (res.isError === true || typeof res.deny === 'string')) {
347 strain = addHit(strain, now, res.isError === true ? SETBACK.toolError : SETBACK.denied)
348 turnHadError = true
349 } else if (!res) {
350 strain = addHit(strain, now, SETBACK.toolError)
351 turnHadError = true
352 }
353 running.delete(id)
354 if (isQuestion || askTool === id) {
355 askTool = ''
356 facts = { ...facts, ask: false }
357 }
358 // Nachlauf (mood.ts TOOL_LINGER_MS): seine Arbeit bleibt kurz die Stimmung, auch wenn das Tool schon fertig ist
359 let endedAt = now
360 try {
361 endedAt = await $.clock.now()
362 } catch {
363 // ohne Uhr zählt der Beginn als Ende: der Nachlauf wird nur kürzer
364 }
365 facts = { ...facts, tool: latestTool(), lastTool: isQuestion ? facts.lastTool : { kind, endedAt } }
366 factsChanged($, endedAt)
367 }
368 })
369
370 on('tool.check', async ($, e, next) => {
371 const decision = await next(e)
372 // Eine Rückfrage ist gemeint, wenn die Kette bei einem echten Aufruf (`tool_use_id`) `ask` meldet. Ob der Modus sie danach selbst
373 // bewilligt (z. B. "Berechtigungen umgehen"), sieht dieses Modul nicht; die Frage gilt bis das Tool endet.
374 if (decision.decision === 'ask' && e.tool_use_id) {
375 askTool = e.tool_use_id
376 let now: number | undefined
377 try {
378 now = await $.clock.now()
379 if (!facts.ask) facts = { ...facts, askSince: now }
380 } catch {
381 // ohne Uhr keine Steigerung, die Rückfrage gilt trotzdem
382 }
383 facts = { ...facts, ask: true }
384 factsChanged($, now)
385 }
386 return decision
387 })
388
389 // Komprimieren des Hauptgesprächs (nicht das Vorausrechnen `precompute`, nicht das eines Subagenten): solange es läuft
390 on('session.compact', async ($, e, next) => {
391 if (e.agentId || e.trigger === 'precompute') return next(e)
392 try {
393 const at = await $.clock.now()
394 facts = { ...facts, compactSince: at }
395 factsChanged($, at)
396 } catch (err) {
397 $.ui.log(`clawd-buddy: clock not read: ${String(err)}`, { to: 'debug' })
398 }
399 try {
400 return await next(e)
401 } finally {
402 facts = { ...facts, compactSince: undefined }
403 factsChanged($)
404 }
405 })
406
407 // Ein Skill startet: kurz zeigen (nur der Name zählt als Ereignis, der Text wird nicht gelesen)
408 on('skill.prompt', async ($, e, next) => {
409 try {
410 const at = await $.clock.now()
411 facts = { ...facts, skillAt: at }
412 factsChanged($, at)
413 } catch (err) {
414 $.ui.log(`clawd-buddy: clock not read: ${String(err)}`, { to: 'debug' })
415 }
416 return next(e)
417 })
418
419 on('prompt.edit', async ($, e, next) => {
420 // Zuerst weiterreichen: das Tippen darf nie verzögert werden. Keine `$`-Aufrufe pro Taste außer dem gedrosselten invalidate.
421 const result = await next(e)
422 typingPerf = performance.now()
423 // Desktop: die Animation sieht das Mitlesen TYPING_MS lang voraus, also reicht eine Neuzeichnung je halbem Fenster
424 if (typingPerf - typingInvalidatedPerf > (deskTimer ? TYPING_MS / 2 : 1000)) {
425 typingInvalidatedPerf = typingPerf
426 $.ui.invalidate('ui.render')
427 }
428 // Anwesenheit höchstens alle 30 s vermerken (erste Taste nach einer Pause zählt sofort)
429 if (typingPerf - seenPerf > 30_000) {
430 seenPerf = typingPerf
431 try {
432 await noteSeen($, await $.clock.now())
433 } catch (err) {
434 $.ui.log(`clawd-buddy: presence not noted: ${String(err)}`, { to: 'debug' })
435 }
436 }
437 return result
438 })
439
440 // ---- Meldungen des Clients (Ärger-Zähler persistieren)
441 on('ui.message', async ($, e, next) => {
442 const data = e.data as { k?: string; annoy?: number } | null
443 if (data && data.k === 'stat' && typeof data.annoy === 'number') {
444 annoy = data.annoy
445 try {
446 await save($)
447 } catch (err) {
448 $.ui.log(`clawd-buddy: saving failed: ${String(err)}`, { to: 'debug' })
449 }
450 }
451 return next(e)
452 })
453
454 // ---- /clawd
455 on('command.run', { command: 'clawd' }, async ($, e) => {
456 // Hilfe: nur `help` oder `?` als einziges Wort (docs/HELP-SPEC.md §2)
457 if (isHelp(e.args)) {
458 const data = clawdHelp({ lang, enabled, nightStart, nightEnd, idleSeconds, reduced, side: flip ? 'left' : 'right', birthday })
459 let now = 0
460 try {
461 now = await $.clock.now()
462 } catch (err) {
463 $.ui.log(`clawd-buddy: clock not read: ${String(err)}`, { to: 'debug' })
464 }
465 const tag = `#${(++helpNo).toString(36)}${now.toString(36).slice(-5).padStart(5, '0')}`
466 helps.set(tag, data)
467 while (helps.size > 10) helps.delete(helps.keys().next().value as string)
468 return { text: helpMarkdown(data, tag) }
469 }
470 // Ohne Argument ist `args` "" (types:1616): dann Status
471 const [first, ...rest] = e.args.trim().split(/\s+/)
472 const sub = first || 'status'
473 const arg = rest.join(' ').trim()
474 // Nur die Unterbefehle aus SUBCOMMANDS (clawdhelp.ts; ein Test prüft jeden gegen die Hilfe). Sonst Kurzhilfe und Verweis (HELP-SPEC §2)
475 if (!(SUBCOMMANDS as readonly string[]).includes(sub)) return { text: `${tx.unknown(sub)} ${tx.help} ${tx.seeHelp}` }
476 // demo und nap schalten ihn ein; das wird wie bei /clawd on gespeichert
477 const ensureOn = async () => {
478 if (enabled) return
479 enabled = true
480 try {
481 await save($)
482 } catch (err) {
483 $.ui.log(`clawd-buddy: saving failed: ${String(err)}`, { to: 'debug' })
484 }
485 }
486 if (sub === 'on' || sub === 'off') {
487 const was = enabled
488 enabled = sub === 'on'
489 if (!enabled) {
490 deskTimer?.cancel()
491 deskTimer = null
492 }
493 try {
494 await save($)
495 } catch (err) {
496 $.ui.log(`clawd-buddy: saving failed: ${String(err)}`, { to: 'debug' })
497 }
498 // Erst direkt vor dem Neuzeichnen: ein Zeichnen während der `await`s davor verbrauchte das Flag sonst mit dem alten Stand (Review 2, S1).
499 if (enabled && !was) deskFresh = true
500 else deskForce = true
501 $.ui.invalidate('ui.render')
502 return { text: enabled ? tx.on : tx.off }
503 }
504 if (sub === 'list') {
505 const groups = new Map<string, string[]>()
506 for (const c of ALL_CLIPS) if (c.cat !== 'transition') groups.set(c.cat, [...(groups.get(c.cat) ?? []), c.name])
507 return { text: [...groups].map(([k, v]) => `${k}: ${v.join(', ')}`).join('\n') }
508 }
509 if (sub === 'demo') {
510 if (!arg) return { text: tx.demoUsage }
511 const exact = ALL_CLIPS.find((c) => c.name === arg)
512 // Gesucht wird in beiden Sprachen (Name, deutsche und englische Bezeichnung)
513 const q = arg.toLowerCase()
514 const part = ALL_CLIPS.filter((c) => c.name.includes(arg) || c.label.toLowerCase().includes(q) || clipLabel('en', c).toLowerCase().includes(q))
515 const hit = exact ?? part[0]
516 if (!hit) return { text: tx.demoNone(arg) }
517 demo = hit.name
518 demoN += 1
519 await ensureOn()
520 // Desktop: wirkt ab der nächsten Zeichnung (desk.ts)
521 desk?.request({ play: hit.name })
522 deskForce = true
523 $.ui.invalidate('ui.render')
524 const more = part.length > 1 && !exact ? tx.demoMore(part.slice(1, 5).map((c) => c.name).join(', ')) : ''
525 return { text: tx.demoPlays(hit.name, clipLabel(lang, hit)) + more }
526 }
527 if (sub === 'nap') {
528 napN += 1
529 await ensureOn()
530 desk?.request('nap')
531 deskForce = true
532 $.ui.invalidate('ui.render')
533 return { text: tx.nap }
534 }
535 if (sub === 'boop') {
536 boopN += 1
537 desk?.request('boop')
538 deskForce = true
539 $.ui.invalidate('ui.render')
540 return { text: 'boop!' }
541 }
542 if (sub === 'flicker') {
543 if (!desk || !deskTimer) return { text: tx.flickerNone }
544 deskDiag = -2
545 deskDiagRounds = 0
546 $.ui.invalidate('ui.render')
547 return { text: tx.flicker }
548 }
549 if (sub === 'status') {
550 let now = facts.endedAt
551 try {
552 now = await $.clock.now()
553 } catch (err) {
554 $.ui.log(`clawd-buddy: clock not read: ${String(err)}`, { to: 'debug' })
555 }
556 const t = deriveTemper(strain, now)
557 const m = tx.mood
558 const mood = t.temper <= -0.6 ? m.veryGrumpy : t.temper <= -0.2 ? m.grumpy : t.temper >= 0.5 ? m.great : t.temper >= 0.2 ? m.good : m.even
559 const deskInfo = meterText(lang, meter, performance.now())
560 meter = newMeter()
561 const text = tx.status({ on: enabled, mood, temper: fmt(lang, t.temper, 2), tired: Math.round(t.tired * 100), annoy, desk: deskInfo })
562 return { text: `${text} ${tx.help}` }
563 }
564 // Nicht erreichbar, solange jeder Eintrag von SUBCOMMANDS oben einen Zweig hat; Schutz, falls beide auseinanderlaufen
565 return { text: `${tx.unknown(sub)} ${tx.help} ${tx.seeHelp}` }
566 })
567
568 // ---- /clawd help als Tabelle an der Stelle der Befehlsausgabe: „a hook's own tree draws in the row's place“ (types CommandOutput).
569 // `command` gehört zu den Props, daher `props: { command }` im Matcher (wie cost-ledger /ledger).
570 on('ui.render', { component: 'CommandOutput', props: { command: 'clawd' } }, async ($, e, next) => {
571 // Nur die Antwort auf `help`/`?`: eine Kennung, die jemand als Argument eintippt (`/clawd demo #…`), zeichnet keine Hilfe
572 // (`args` ist ein Prop von CommandOutput, types@2.1.295:10044-10048)
573 if (e.props.isErrored || (e.surface !== 'terminal' && e.surface !== 'desktop') || !isHelp(e.props.args ?? '')) return next(e)
574 // Die Kennung nicht am Zeilenanfang suchen: vor der Antwort steht „clawd-buddy: “ (templates/help/README.md, Befunde)
575 const tag = /#[0-9a-z]{5,}/.exec(e.props.text.split('\n')[0] ?? '')?.[0]
576 const data = tag ? helps.get(tag) : undefined
577 return data ? helpTree(data, e.viewport?.columns ?? 100, e.surface, ACCENT) : next(e)
578 })
579
580 // ---- Zeichnen
581 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
582 // Was andere Mods ins Band zeichnen, bleibt erhalten (interface.md:210)
583 const drawStart = performance.now()
584 const theirs = await next(e)
585 const othersMs = performance.now() - drawStart
586 try {
587 if (!enabled) return theirs
588 if (e.surface !== 'terminal' && e.surface !== 'desktop') return theirs
589 // Ebenen anderer Mods (Quick-Replies, sidekick) bleiben über dem Grund, nie neben Clawd, egal wer in der Kette außen liegt
590 // (band.ts, docs/BAND.md). Clawd kommt nur neben den Grund (z. B. die Limit-Balken).
591 const { layers, base } = splitBand(theirs)
592 const now = await $.clock.now()
593 // Schnittstelle zu sidekick: dessen `$.state`-Wert lesen. Das Lesen beim Zeichnen abonniert ihn, ein Schreiben von sidekick
594 // zeichnet das Band neu (types:3308-3313). Ohne sidekick bleibt er leer.
595 try {
596 const sk = await $.state.get({ plugin: 'sidekick', key: 'buddy' })
597 const v = sidekickValue(sk.value)
598 if (JSON.stringify(v) !== JSON.stringify(facts.sidekick ?? null)) facts = { ...facts, sidekick: v }
599 } catch (err) {
600 $.ui.log(`clawd-buddy: sidekick state not read: ${String(err)}`, { to: 'debug' })
601 }
602 // Client-Props dürfen kein `undefined` enthalten (die Engine lehnt sie ab): leere Felder weglassen
603 const f: Facts = JSON.parse(JSON.stringify(facts)) as Facts
604 if (typingPerf > 0) f.typingAt = now - (performance.now() - typingPerf)
605 const { Box, Client } = $.ui.resolve(e)
606 const buddyProps = {
607 // Zeichnet ein anderer Mod links daneben, bekommt er 44 Spalten (Clawd behält mindestens 40)
608 columns: base ? Math.min(e.props.bodyColumns, Math.max(40, e.props.bodyColumns - 44)) : e.props.bodyColumns,
609 maxRows: e.props.maxRows,
610 now,
611 nightStart,
612 nightEnd,
613 idleSeconds,
614 reduced,
615 flip,
616 birthday,
617 seed,
618 facts: f,
619 strain,
620 demo,
621 demoN,
622 napN,
623 boopN,
624 annoy,
625 }
626 if (e.surface === 'desktop') {
627 // Desktop: Engine im Hooks-Modul, Bild als animiertes Svg (deckende Figur auf durchsichtigem Grund), direkt über der Eingabe.
628 if (!desk) {
629 desk = createDesk({ seed, nightStart, nightEnd, idleSeconds, reduced, flip, birthday })
630 }
631 let stalled = false
632 if (deskTimer && deskBeats !== deskBeatsSeen) {
633 deskBeatsSeen = deskBeats
634 deskBeatsSeenAt = now
635 } else if (deskTimer && now - deskBeatsSeenAt >= DESK_IDLE_CHECKS * DESK_CHECK) {
636 // Keine Runde seit ~2 s: eine Periode wurde abgelehnt, der Wächter ist still beendet
637 deskTimer.cancel()
638 deskTimer = null
639 stalled = true
640 // Die tote Zeit nachziehen: deskPlanMs rechnet in Uhrzeit, sonst käme die nächste Bitte um diese Zeit zu spät
641 deskChecks = Math.max(deskChecks, Math.floor((now - deskDrawnAt) / DESK_CHECK))
642 $.ui.log('clawd-buddy: desk watcher stalled (period refused?), restarted', { to: 'debug' })
643 }
644 if (!deskTimer) {
645 if (!stalled) {
646 deskFresh = true // die Sitzung wird (wieder) angezeigt: neu zeichnen
647 // Der Wächter startet neu: die Sitzung wird (wieder) angezeigt, Fynn schaut also hin
648 try {
649 await noteSeen($, now)
650 } catch (err) {
651 $.ui.log(`clawd-buddy: presence not noted: ${String(err)}`, { to: 'debug' })
652 }
653 }
654 deskBeats = 0
655 deskBeatsSeen = 0
656 deskBeatsSeenAt = now
657 deskTimer = $.clock.every(DESK_CHECK, () => {
658 deskBeats += 1
659 // Aus oder die App zeichnet das Band nicht mehr (z. B. Sitzung verdeckt): Wächter beenden; das nächste Zeichnen startet ihn neu
660 if (!desk || !enabled || deskAsked > DESK_IDLE_CHECKS) {
661 deskTimer?.cancel()
662 deskTimer = null
663 return
664 }
665 if (deskDiag !== -1) {
666 // Flacker-Test: alle zwei Runden zeichnen; die Bilder hängen nur an der Uhrzeit
667 deskAsked = 0
668 deskChecks += 1
669 deskDiagRounds += 1
670 if (deskDiagRounds > DIAG_MS / DESK_CHECK + DESK_IDLE_CHECKS) {
671 deskDiag = -1
672 deskFresh = true
673 }
674 if (deskChecks % 2 === 0) $.ui.invalidate('ui.render')
675 return
676 }
677 deskChecks += 1
678 if (deskAsked > 0) deskAsked += 1
679 else if (deskChecks * DESK_CHECK >= deskPlanMs - DESK_LEAD) {
680 deskAsked = 1
681 $.ui.invalidate('ui.render')
682 }
683 })
684 }
685 const { Svg } = $.ui.resolve(e)
686 const deskBand = (source: string, frame?: true) => joinBand(layers, Box({
687 flexDirection: flip ? 'row-reverse' : 'row',
688 alignItems: 'flex-end',
689 // Jede Seite drückt ihren Inhalt selbst nach unten (Spalte, justifyContent flex-end): die Desktop-App setzt alignItems der
690 // Zeile nicht immer um, und je nach Reihenfolge der Mods saßen fremde Balken sonst oben statt auf Höhe von Clawds Füßen.
691 children: [
692 Box({ flexGrow: 1, flexDirection: 'column', justifyContent: 'flex-end', children: [base] }),
693 Box({
694 key: 'buddy',
695 flexDirection: 'column',
696 justifyContent: 'flex-end',
697 // Nie zusammendrücken: sonst schneidet ein schmales Band (Seitenbereich offen) das 400-px-Bild rechts ab, und mit ihm die Hand
698 // (Fynn, 2026-10-06). Schmaler wird die linke Seite mit flexGrow.
699 flexShrink: 0,
700 // Als Bild (ohne isInteractive): SMIL läuft, und beim Wechsel bleibt das alte stehen, bis das neue fertig ist. Den Rahmen
701 // (`frame`) gibt es nur noch im Flacker-Test zum Vergleich.
702 children: [Svg({ source, alt: tx.alt, width: W * DESK_SCALE, height: H * DESK_SCALE, ...(frame ? { isInteractive: frame } : {}) })],
703 }),
704 ],
705 }))
706 if (deskDiag !== -1) {
707 if (deskDiag === -2) deskDiag = now
708 const t = now - deskDiag
709 if (t < DIAG_MS) {
710 const step = Math.floor(t / DIAG_STEP)
711 const part = Math.floor(t / (DIAG_MS / 2)) + 1
712 const still = stillSource(DESK_SCALE, flip, part, step)
713 return part === 1 ? deskBand(still) : deskBand(still, true)
714 }
715 deskDiag = -1
716 deskFresh = true
717 }
718 const calcStart = performance.now()
719 const fNow = deskFacts(now)
720 // Ändern die neuen Fakten an der gezeigten Animation (vorerst) nichts, bleibt das Svg dasselbe: kein Neuladen, kein Flackern
721 const shape = `${layers.map((l) => nameOf(l)).join(',')}|${e.props.bodyColumns}|${e.props.maxRows}`
722 const fresh = deskFresh || deskSvg === '' || shape !== deskShape
723 const keep = !fresh && !deskForce && deskAsked === 0 && now - deskDrawnAt < DESK_REUSE_MS
724 const div = keep ? desk.divergence(now, fNow, strain) : 0
725 const left = keep ? desk.remaining(now) : 0
726 if (keep && div > DESK_LEAD && left > DESK_LEAD) {
727 // Der Wächter bittet kurz vor der Abweichung bzw. dem Ende um die nächste Zeichnung (Runden zählen ab der letzten echten)
728 deskPlanMs = now - deskDrawnAt + Math.min(div, left)
729 meter.reused += 1
730 } else {
731 // Stand so auf jetzt nachziehen, wie die gezeigte Animation lief, dann ab jetzt mit den neuen Fakten vorausrechnen
732 const plan = desk.draw(now, fNow, strain, DESK_SCALE)
733 if (fresh) {
734 if (!deskFresh && shape !== deskShape && deskSvg !== '') meter.freshShape += 1
735 else meter.freshShown += 1
736 }
737 deskSvg = plan.source
738 deskDrawnAt = now
739 deskShape = shape
740 deskForce = false
741 deskFresh = false
742 deskPlanMs = plan.ticks * DESK_TICK
743 deskChecks = 0
744 deskAsked = 0
745 const calc = performance.now() - calcStart
746 meter.draws += 1
747 meter.planSecs += deskPlanMs / 1000
748 meter.changes += plan.changes
749 meter.chars += plan.source.length
750 meter.calcSum += calc
751 meter.calcMax = Math.max(meter.calcMax, calc)
752 }
753 const drawMs = performance.now() - drawStart
754 meter.drawSum += drawMs
755 meter.drawMax = Math.max(meter.drawMax, drawMs)
756 meter.othersSum += othersMs
757 return deskBand(deskSvg)
758 }
759 // Eine Zeile: was andere Mods ins Band zeichnen (z. B. Limit-Balken), steht links und füllt den Platz; Clawd unten rechts daneben
760 return joinBand(layers, Box({
761 flexDirection: flip ? 'row-reverse' : 'row',
762 alignItems: 'flex-end',
763 children: [
764 Box({ flexGrow: 1, flexDirection: 'column', justifyContent: 'flex-end', children: [base] }),
765 Box({ flexDirection: 'column', justifyContent: 'flex-end', children: [Client({ key: 'buddy', module: './buddy.ts', props: buddyProps })] }),
766 ],
767 }))
768 } catch (err) {
769 $.ui.log(`clawd-buddy ui.render: ${String(err)}`, { to: 'debug' })
770 return theirs
771 }
772 })
773}
774hooks/band.ts 108 lines1// band.ts: gemeinsames Protokoll für das Band über dem Prompt (AbovePrompt). Gleiche Kopie in limit-bars, clawd-buddy,
2// quick-replies und sidekick (Mods importieren nur relativ); Beschreibung in docs/BAND.md, Änderungen immer in allen Kopien.
3//
4// Die Reihenfolge der Mods in der Kette hängt von der Installation ab (docs/raw/en/events.md:291-305). Damit das Band trotzdem
5// immer gleich aussieht, gibt es zwei Arten von Inhalt:
6// - Grund: was nebeneinander in einer Zeile steht (Limit-Balken links, Clawd rechts). Jeder Mod setzt seinen Teil neben den Grund.
7// - Ebenen: was als eigene Zeile über dem Grund steht (Quick-Replies, sidekick). Ein Box-Knoten mit key `layer:<Höhe>:<Name>`.
8// Jeder Mod holt die Ebenen aus dem, was `next` liefert, heraus, baut nur den Grund um und setzt die Ebenen wieder obenauf,
9// die höchste zuoberst. So wandert keine Ebene in die Zeile eines anderen Mods, egal wer außen liegt.
10import type { RenderElement, RenderNode } from 'claude-code'
11
12const LAYER = 'layer:'
13const ROOT = 'band'
14const BASE = 'band-base'
15
16export type Split = { layers: RenderElement[]; base: RenderNode | null }
17
18type Data = { type?: unknown; props?: Record<string, unknown>; children?: unknown[] }
19
20const keyOf = (n: unknown): string => {
21 const k = (n as Data | null)?.props?.key
22 return typeof k === 'string' ? k : ''
23}
24
25/** Höhe einer Ebene aus ihrem key (`layer:20:quick-replies` → 20); kein Ebenen-key → -1. */
26export function levelOf(n: unknown): number {
27 const k = keyOf(n)
28 if (!k.startsWith(LAYER)) return -1
29 const v = Number(k.slice(LAYER.length).split(':')[0])
30 return Number.isFinite(v) ? v : 0
31}
32
33/** Name einer Ebene (`layer:20:quick-replies` → quick-replies). */
34export function nameOf(n: unknown): string {
35 const k = keyOf(n)
36 return k.startsWith(LAYER) ? k.slice(LAYER.length).split(':').slice(1).join(':') : ''
37}
38
39/** Den Grund aus einer Wurzel holen, die joinBand gebaut hat. */
40function baseOf(root: Data): unknown {
41 const wrap = (root.children ?? []).find((c) => keyOf(c) === BASE) as Data | undefined
42 const inner = wrap?.children?.[0] as Data | undefined
43 return inner?.children?.[0] ?? null
44}
45
46function lift(node: unknown, out: RenderElement[]): unknown {
47 if (!node || typeof node !== 'object') return node
48 const n = node as Data
49 if (levelOf(n) >= 0) {
50 out.push(n as RenderElement)
51 return null
52 }
53 // Wurzel eines anderen Mods: ihre Ebenen einsammeln, an ihrer Stelle steht nur noch ihr Grund
54 if (keyOf(n) === ROOT) {
55 for (const c of n.children ?? []) if (levelOf(c) >= 0) out.push(c as RenderElement)
56 return lift(baseOf(n), out)
57 }
58 if (!Array.isArray(n.children)) return node
59 let changed = false
60 const kids: unknown[] = []
61 for (const c of n.children) {
62 const l = lift(c, out)
63 if (l !== c) changed = true
64 if (l !== null) kids.push(l)
65 }
66 // Nichts gefunden: derselbe Knoten, damit ein Band ohne Ebenen genau so bleibt, wie es war
67 return changed ? { ...n, children: kids } : node
68}
69
70/** Ebenen aus dem Ergebnis von `next` herausholen, auch aus fremden Hüllen; der Rest ist der Grund. */
71export function splitBand(theirs: RenderNode | null | undefined): Split {
72 const layers: RenderElement[] = []
73 const base = lift(theirs ?? null, layers) as RenderNode | null
74 return { layers, base }
75}
76
77/** Ebenen (höchste oben) über den Grund setzen; ohne Ebenen bleibt der Grund unverändert. */
78export function joinBand(layers: readonly RenderElement[], base: RenderNode | null | undefined): RenderNode | null {
79 if (layers.length === 0) return base ?? null
80 const sorted = layers
81 .map((l, i) => ({ l, i }))
82 .sort((a, b) => levelOf(b.l) - levelOf(a.l) || a.i - b.i)
83 .map((x) => x.l)
84 const kids: RenderNode[] = [...sorted]
85 if (base !== null && base !== undefined) {
86 // Der Grund steht in einer eigenen Zeile unten bündig, nie direkt in der Spalte (quick-replies, Lehren 4 und 5)
87 kids.push(
88 box({ key: BASE, flexDirection: 'row', alignItems: 'flex-end' }, [
89 box({ flexGrow: 1, flexDirection: 'column', justifyContent: 'flex-end' }, [base]),
90 ]),
91 )
92 }
93 return box({ key: ROOT, flexDirection: 'column', justifyContent: 'flex-end' }, kids)
94}
95
96/** Eine Ebene: eigener Box-Knoten mit `layer:<Höhe>:<Name>`, der Inhalt unverändert darin. */
97export function layer(level: number, name: string, content: RenderNode): RenderElement {
98 return box({ key: `${LAYER}${level}:${name}`, flexDirection: 'column', flexShrink: 0 }, [content])
99}
100
101// Elemente als reine Daten (types@2.1.289:11659-11684), ohne $.ui.resolve
102function box(props: Record<string, string | number | boolean>, children: RenderNode[]): RenderElement {
103 return { type: 'Box', props, children } as RenderElement
104}
105
106/** Höhen der Ebenen: weiter oben = größere Zahl. */
107export const LEVEL = { quickReplies: 20, sidekick: 30 } as const
108hooks/library.ts 41 lines1// clawd-buddy: Bibliothek = alle Requisiten und Clips an einer Stelle (reine Daten, nur Importe und Zusammenführung).
2// Eine neue Clip-Gruppe wird hier eingetragen. Requisiten-Namen müssen über alle Gruppen eindeutig sein.
3import { BASE_PROPS } from './props.ts'
4import type { PropTable } from './stage.ts'
5import type { ClipDef } from './clipdef.ts'
6import * as idle from './clips/idle.ts'
7import * as fun from './clips/fun.ts'
8import * as work from './clips/work.ts'
9import * as agent from './clips/agent.ts'
10import * as wait from './clips/wait.ts'
11import * as done from './clips/done.ts'
12import * as night from './clips/night.ts'
13import * as mouse from './clips/mouse.ts'
14import * as transitions from './clips/transitions.ts'
15import * as sidekick from './clips/sidekick.ts'
16import * as extra from './clips/extra.ts'
17import * as hints from './clips/hints.ts'
18
19const groups = [idle, fun, work, agent, wait, done, night, mouse, transitions, sidekick, extra, hints]
20
21export const ALL_CLIPS: readonly ClipDef[] = groups.flatMap((g) => g.CLIPS)
22/**
23 * Requisiten mit Schrift, Zeichen oder Uhrzeigern: beim Spiegeln eines Clips nicht umdrehen (Fynn: ein gespiegeltes „?“ ist keins).
24 * Fragezeichen/Ausrufezeichen, zZ, Häkchen, Fragezeichen-Schild, Klemmbrett, Test-Liste, Kalender, Uhren, Upload-Pfeil; sidekick: Haken, „!“.
25 */
26const NO_FLIP = /^(question|excl|zs|zb|mood_excl|mood_z\d|wait_sign|wait_cal\d|wait_clock\d|work_ok\d|work_ex|work_zB|work_zS|work_cb\d|work_up|sk_tick|sk_excl)$/
27const merged: PropTable = Object.assign({}, BASE_PROPS, ...groups.map((g) => g.PROPS))
28export const ALL_PROPS: PropTable = Object.fromEntries(
29 Object.entries(merged).map(([k, v]) => [k, NO_FLIP.test(k) ? { ...v, noFlip: true as const } : v]),
30)
31
32/** Anzeigenamen der Kategorien (Galerie und Prüfwerkzeuge). */
33export const CAT_LABEL: Readonly<Record<string, string>> = {
34 idle: 'Grundpose', fun: 'Zeitvertreib', work_write: 'Arbeiten: schreiben', work_read: 'Arbeiten: lesen',
35 work_shell: 'Arbeiten: Shell', work_web: 'Arbeiten: Web', work_agent: 'Arbeiten: Subagent', work_git: 'Arbeiten: Commit/Push', work_test: 'Arbeiten: Tests/Checks', limit_5h: 'Limit: 5 Stunden erreicht', limit_week: 'Limit: Woche erreicht', limit_back: 'Limit: zurückgesetzt', agent_done: 'Subagent fertig', streak: 'Erfolgsserie', waitUserLong: 'Wartet lange auf dich', work_think: 'Arbeiten: denken',
36 wait10: 'Warten (> 10 s)', wait60: 'Warten (> 60 s)', waitUser: 'Wartet auf dich', watching: 'Du tippst',
37 sk_check: 'sidekick prüft deine Nachricht', sk_stop: 'sidekick hält deine Nachricht an', sk_handoff: 'sidekick baut einen neuen Chat', sk_fresh: 'Neuer Chat ist da',
38 compact: 'Komprimiert den Verlauf', skill: 'Skill startet', ctx_full: 'Kontext fast voll', done_long: 'Langer Turn geschafft',
39 done: 'Fertig', oops: 'Fehler', night: 'Nacht', morning: 'Morgen', mouse: 'Maus', transition: 'Übergänge',
40}
41hooks/mood.ts 317 lines1// clawd-buddy: Stimmungsableitung (reine Funktionen, kein `$`, kein DOM).
2//
3// Das Hooks-Modul sammelt nur *Fakten* aus den Events (wann begann der Turn, welches Tool läuft seit wann, ob eine Frage offen ist,
4// wann zuletzt getippt wurde, wie der letzte Turn endete) und reicht sie als `props` an den Client. Aus Fakten und der Zeit leitet
5// `deriveMood` die Stimmung ab. Das passiert im Client, auf seiner eigenen Uhr: so werden Schwellen wie "Tool läuft > 10 s"
6// auch ohne Timer im Hooks-Modul erkannt (ein Hooks-Modul darf nicht warten, nur Hooks-API-Aufrufe), und `$.ui.invalidate` ist nur bei
7// echten Ereignissen nötig, nie im Bildtakt.
8// Priorität (SPEC → Stimmungen): wartet auf dich > Tool läuft lange > arbeitet > fertig/Fehler > Nutzer tippt > Leerlauf.
9
10export type ToolKind = 'read' | 'write' | 'shell' | 'web' | 'agent' | 'think' | 'git' | 'test'
11
12/**
13 * Shell-Befehl genauer einordnen (Fynn: Commits und Checks laufen oft): nur ein Mustervergleich auf dem Befehl, der Befehl selbst
14 * wird weder gespeichert noch weitergegeben. `git` = commit/push/merge/rebase/tag, `test` = Tests, Lint, Typprüfung, Validierung.
15 */
16export function shellKind(command: unknown): ToolKind {
17 if (typeof command !== 'string') return 'shell'
18 const c = command.toLowerCase()
19 if (/\bgit\s+(-c\s+\S+\s+)*(commit|push|merge|rebase|tag|cherry-pick)\b/.test(c)) return 'git'
20 if (/\b(test|tests|pytest|vitest|jest|mocha|lint|eslint|tsc|typecheck|validate|check|check-mods)\b/.test(c)) return 'test'
21 return 'shell'
22}
23
24export type Facts = {
25 /** Läuft ein Turn des Hauptagenten? */
26 turnActive: boolean
27 /** Zuletzt gestartetes, noch laufendes Tool des Hauptagenten (nicht der Subagenten). */
28 tool: { kind: ToolKind; since: number } | null
29 /** Zuletzt beendetes Tool des Hauptagenten in diesem Turn und wann es endete (ms): Nachlauf, siehe TOOL_LINGER_MS. */
30 lastTool?: { kind: ToolKind; endedAt: number }
31 /** Eine Frage an den Nutzer ist offen (AskUserQuestion bzw. Berechtigung `ask`). */
32 ask: boolean
33 /** Zeitpunkt der letzten Eingabe im Prompt (ms, 0 = nie). */
34 typingAt: number
35 /** Ende des letzten Turns (ms) und wie er endete. */
36 endedAt: number
37 endedKind: '' | 'done' | 'oops'
38 /**
39 * Laufende Subagenten (auch im Hintergrund, in Worktrees, in Workflows, Teammates): je Agent der Zeitpunkt seiner letzten Aktivität
40 * (ms). Ein Agent zählt, bis sein `turn.complete` kommt oder er AGENT_STALE_MS lang nichts mehr tut.
41 */
42 agents?: readonly number[]
43 /** Seit wann die Rückfrage offen ist (ms); nach WAIT_ASK_LONG_MS wird er deutlicher (`waitUserLong`). */
44 askSince?: number
45 /** Zeitpunkt (ms), an dem eine Erfolgsserie eine Marke erreicht hat (5, 10, 15 … Turns in Folge): kurz `streak`. */
46 streakAt?: number
47 /** Zeitpunkt (ms), an dem zuletzt ein Subagent fertig wurde: kurz danach überreicht sein Helfer ein Geschenk (`agent_done`). */
48 agentDoneAt?: number
49 /**
50 * Nutzungslimits aus `session.measure` (types:10379-10405, SessionRateLimit types:10566): Prozent verbraucht und Reset-Zeitpunkt
51 * (ms) für das 5-Stunden- und das Wochenfenster. Fehlt ohne Abo oder vor der ersten Messung.
52 */
53 limits?: { five?: number; fiveReset?: number; week?: number; weekReset?: number }
54 /** Zeitpunkt (ms), an dem Fynn nach mindestens AWAY_MS Pause zurückkam (erste Eingabe bzw. Sitzung wieder sichtbar): Begrüßung. */
55 backAt?: number
56 /**
57 * Was der Mod sidekick gerade tut, aus dessen `$.state`-Wert `sidekick.buddy` (types/index.d.ts): prüft die Nachricht, hält sie an,
58 * baut einen neuen Chat, neuer Chat eben gestartet. Ohne sidekick leer.
59 */
60 sidekick?: { kind: 'check' | 'stop' | 'handoff' | 'fresh'; at: number } | null
61 /** Seit wann der Verlauf komprimiert wird (session.compact des Hauptgesprächs, ms; fehlt = gerade nicht). */
62 compactSince?: number
63 /** Zeitpunkt (ms), an dem zuletzt ein Skill startete (skill.prompt): kurz `skill`. */
64 skillAt?: number
65 /**
66 * Zeitpunkt (ms), an dem er zeigt, dass das Kontextfenster voll wird (eine Stufe aus CTX_WARN überschritten; gezeigt am Turn-Ende,
67 * nie mitten in der Arbeit): kurz `ctx_full`. Nur die Prozentzahl aus `session.measure` zählt (types:10771), nie der Verlauf.
68 */
69 ctxAt?: number
70 /** Dauer des zuletzt beendeten Turns (ms): ab LONG_TURN_MS heißt „fertig“ `done_long` (Puh, geschafft). */
71 turnMs?: number
72}
73
74/** Ab so viel Prozent gilt ein Fenster als ausgeschöpft. */
75export const LIMIT_FULL = 100
76/** So lange nach dem Reset eines ausgeschöpften Fensters freut er sich (ms). */
77export const LIMIT_BACK_MS = 6_000
78
79/** Welches Limit ist gerade ausgeschöpft ('week' vor 'five'), oder 'back' kurz nach dem Reset, sonst ''. */
80export function limitState(f: Facts, now: number): '' | 'five' | 'week' | 'back' {
81 const l = f.limits
82 if (!l) return ''
83 const full = (p?: number, reset?: number) => (p ?? 0) >= LIMIT_FULL && (!reset || now < reset)
84 if (full(l.week, l.weekReset)) return 'week'
85 if (full(l.five, l.fiveReset)) return 'five'
86 // Eben zurückgesetzt: Fenster war voll, der Reset-Zeitpunkt ist gerade vorbei
87 const back = (p?: number, reset?: number) => (p ?? 0) >= LIMIT_FULL && !!reset && now >= reset && now - reset < LIMIT_BACK_MS
88 if (back(l.week, l.weekReset) || back(l.five, l.fiveReset)) return 'back'
89 return ''
90}
91
92export const WAIT_LONG_MS = 10_000
93export const WAIT_STRONG_MS = 60_000
94export const TYPING_MS = 6_000 // kurze Tipp-Pausen beenden das Mitlesen/Mitschreiben nicht
95export const ENDED_MS = 4_000
96/**
97 * Nachlauf: So lange nach dem Ende eines Tools bleibt dessen Arbeit die Stimmung, bis das nächste Tool beginnt. Read und Edit dauern
98 * meist unter 0,5 s und gingen sonst in der Entprellung unter (Laptop nie zu sehen); die Denkpausen zwischen zwei Tools ließen ihn
99 * alle paar Sekunden zwischen Arbeit und Grübeln wechseln (Fynn, 2026-10-06: „wechselt so mittendrin“; tools/flow.mjs).
100 */
101export const TOOL_LINGER_MS = 8_000
102
103export const NO_FACTS: Facts = { turnActive: false, tool: null, ask: false, typingAt: 0, endedAt: 0, endedKind: '' }
104
105/** Tool-Name → Art (SPEC → Stimmungen). Unbekannte Tools (z. B. MCP) zählen als "denkt". */
106export function toolKind(tool: string): ToolKind {
107 switch (tool) {
108 case 'Read':
109 case 'Grep':
110 case 'Glob':
111 return 'read'
112 case 'Edit':
113 case 'Write':
114 case 'NotebookEdit':
115 return 'write'
116 case 'Bash':
117 case 'PowerShell':
118 return 'shell'
119 case 'WebFetch':
120 case 'WebSearch':
121 return 'web'
122 case 'Task':
123 case 'Agent':
124 return 'agent'
125 default:
126 return 'think'
127 }
128}
129
130const WORK: Record<ToolKind, string> = {
131 read: 'work_read', write: 'work_write', shell: 'work_shell', web: 'work_web', agent: 'work_agent', think: 'work_think',
132 git: 'work_git', test: 'work_test',
133}
134
135// ---- Laune (Fynn: "dass sich das ein bisschen steigern kann")
136//
137// Neben der Stimmung (was er gerade tut) hat er eine Laune, die sich über die Sitzung aufbaut und langsam wieder abklingt:
138// - Frust: jeder Rückschlag (Tool meldet Fehler, Tool abgelehnt, Turn endet nicht mit einer Antwort) zählt, ältere weniger
139// (Halbwertszeit FRUST_HALF_MS). Ein erfolgreicher Turn ohne Fehler baut ihn wieder ab.
140// - Müdigkeit: wie lange am Stück gearbeitet wurde (Turn-Zeit), ohne längere Pause (BREAK_MS). Das ist der Näherungswert für
141// "schon länger an demselben Feature": den Inhalt der Arbeit sieht der Mod nicht und liest ihn auch nicht.
142// - Gute Laune: mehrere erfolgreiche Turns in Folge.
143// Gelesen werden nur Ereignisse und die Markierung `isError` eines Tool-Ergebnisses (types:12028), nie Texte.
144
145export const FRUST_HALF_MS = 20 * 60_000
146export const BREAK_MS = 20 * 60_000
147export const TIRED_FROM_MS = 45 * 60_000
148export const TIRED_FULL_MS = 120 * 60_000
149
150export const SETBACK = { toolError: 1, denied: 0.5, turnFailed: 2, success: -1.5 } as const
151
152export type Strain = {
153 /** Rückschläge und Erfolge: [Zeitpunkt ms, Gewicht] (Erfolge negativ), die letzten 24. */
154 hits: readonly (readonly [number, number])[]
155 /** Arbeitszeit am Stück (ms, abgeschlossene Turns) und Ende des letzten Turns. */
156 workMs: number
157 lastWorkAt: number
158 /** Beginn des laufenden Turns (0 = keiner). */
159 turnSince: number
160 /** Erfolgreiche Turns in Folge (ohne Fehler). */
161 streak: number
162}
163
164export const NO_STRAIN: Strain = { hits: [], workMs: 0, lastWorkAt: 0, turnSince: 0, streak: 0 }
165
166export function addHit(s: Strain, now: number, w: number): Strain {
167 return { ...s, hits: [...s.hits, [now, w] as const].slice(-24) }
168}
169
170/** Turn beginnt: nach einer langen Pause fängt die Arbeitszeit am Stück neu an. */
171export function strainTurnStart(s: Strain, now: number): Strain {
172 const fresh = s.lastWorkAt && now - s.lastWorkAt > BREAK_MS
173 return { ...s, workMs: fresh ? 0 : s.workMs, turnSince: now }
174}
175
176/** Turn endet: Arbeitszeit aufaddieren, Erfolg oder Rückschlag verbuchen. `clean` = ohne Tool-Fehler im Turn. */
177export function strainTurnEnd(s: Strain, now: number, ok: boolean, clean: boolean): Strain {
178 const worked = s.turnSince ? Math.max(0, now - s.turnSince) : 0
179 const base = { ...s, workMs: s.workMs + worked, lastWorkAt: now, turnSince: 0 }
180 if (!ok) return { ...addHit(base, now, SETBACK.turnFailed), streak: 0 }
181 if (!clean) return { ...base, streak: 0 }
182 return { ...addHit(base, now, SETBACK.success), streak: base.streak + 1 }
183}
184
185export type Temper = {
186 /** -1 (sehr gereizt) … 0 (normal) … +1 (bester Laune). */
187 temper: number
188 /** 0 … 1 */
189 tired: number
190}
191
192/** Laune zur Zeit `now`. Rein und deterministisch (Client rechnet sie auf seiner Uhr). */
193export function deriveTemper(s: Strain, now: number): Temper {
194 let sum = 0
195 // In zeitlicher Reihenfolge, nie unter 0: Erfolge bauen Frust ab, sammeln aber kein Guthaben gegen spätere Rückschläge an
196 for (const [t, w] of s.hits) sum = Math.max(0, sum + w * Math.pow(0.5, Math.max(0, now - t) / FRUST_HALF_MS))
197 const frust = 1 - Math.exp(-sum / 3)
198 const good = Math.min(1, s.streak / 5) * (1 - frust)
199 const running = s.turnSince ? Math.max(0, now - s.turnSince) : 0
200 const pause = s.lastWorkAt && !s.turnSince ? now - s.lastWorkAt : 0
201 const work = pause > BREAK_MS ? 0 : s.workMs + running
202 const tired = Math.max(0, Math.min(1, (work - TIRED_FROM_MS) / (TIRED_FULL_MS - TIRED_FROM_MS)))
203 return { temper: Math.max(-1, Math.min(1, good - frust)), tired }
204}
205
206/** Ab so langer offener Rückfrage wird er deutlicher (klopft an die Eingabe). */
207export const WAIT_ASK_LONG_MS = 45_000
208/** Jede so vielte erfolgreiche Antwort in Folge ist eine Marke für den Pokal. */
209export const STREAK_STEP = 5
210
211/**
212 * Besonderer Tag aus Datum und Geburtstag ("TT.MM.", userConfig `birthday`): Geburtstag den ganzen Tag, Silvester vom 31.12.
213 * 18 Uhr bis 1.1. 12 Uhr. Lokale Zeit der Hooks-Uhr.
214 */
215export function specialDay(now: number, birthday: string): '' | 'birthday' | 'newyear' {
216 const d = new Date(now)
217 const m = d.getMonth() + 1
218 const day = d.getDate()
219 const h = d.getHours()
220 const b = /^(\d{1,2})\.(\d{1,2})\.?$/.exec(birthday.trim())
221 if (b && Number(b[1]) === day && Number(b[2]) === m) return 'birthday'
222 if ((m === 12 && day === 31 && h >= 18) || (m === 1 && day === 1 && h < 12)) return 'newyear'
223 return ''
224}
225
226/** So lange nach dem Start eines Skills zeigt er ihn (ms). */
227export const SKILL_MS = 4_000
228/** So lange nach dem Start eines neuen Chats durch sidekick freut er sich darüber (ms). */
229export const SK_FRESH_MS = 6_000
230/**
231 * Sicherheitsnetz, falls sidekick einen Zustand nicht mehr zurücksetzt (z. B. Neuladen mitten in der Prüfung): so lange gilt er
232 * höchstens (ms). Die Prüfung dauert sonst 1–6 s, der Dialog wartet auf Fynn, die Übergabe braucht bis ~45 s.
233 */
234export const SK_MAX_MS = { check: 30_000, stop: 10 * 60_000, handoff: 3 * 60_000, fresh: SK_FRESH_MS } as const
235/** Nur gültige Werte von sidekick übernehmen (ein dritter Mod könnte ihn umschreiben, Review K2), sonst `null`. */
236export function sidekickValue(v: unknown): Facts['sidekick'] {
237 if (!v || typeof v !== 'object') return null
238 const o = v as { kind?: unknown; at?: unknown }
239 const kinds = ['check', 'stop', 'handoff', 'fresh'] as const
240 const kind = kinds.find((k) => k === o.kind)
241 return kind && typeof o.at === 'number' && Number.isFinite(o.at) ? { kind, at: o.at } : null
242}
243
244/**
245 * Füllstände des Kontextfensters (Prozent), bei denen er einmal zeigt, dass es voll wird: Zeit für `/compact` oder einen neuen Chat.
246 * Jede Stufe meldet er nur einmal; fällt der Füllstand darunter (Komprimieren, `/clear`), ist sie wieder scharf.
247 */
248export const CTX_WARN = [70, 85] as const
249/** Höchste überschrittene Warnstufe zu `percent` (0 = keine). */
250export function ctxLevel(percent: number): number {
251 let lvl = 0
252 for (const l of CTX_WARN) if (percent >= l) lvl = l
253 return lvl
254}
255/** Ein Turn ab dieser Dauer gilt als lang: am Ende ein erleichtertes „Puh, geschafft“ statt des normalen Abschlusses. */
256export const LONG_TURN_MS = 5 * 60_000
257
258/** Ein Komprimieren ohne Ende (Hook-Fehler) gilt nach so langer Zeit als vorbei (ms). */
259export const COMPACT_MAX_MS = 5 * 60_000
260
261/** Ein Subagent ohne Aktivität so lange gilt als beendet (falls sein Ende nicht gemeldet wurde). */
262export const AGENT_STALE_MS = 600_000 // 10 min: Subagenten denken im Hintergrund auch mal länger ohne Tool-Aufruf
263/** Ab so vielen gleichzeitig laufenden Subagenten: Schwarm (Fynn: "ganz, ganz viele, ein bisschen übertrieben"). */
264/** So lange nach dem Ende eines Subagenten gilt `agent_done` (ms). */
265export const AGENT_DONE_MS = 4_000
266/** So lange ohne Eingabe in dieser Sitzung gilt als „weg“; danach begrüßt er dich mit einer Aufsteh-Animation (statt nach Uhrzeit). */
267export const AWAY_MS = 2 * 3_600_000
268/** Eine Rückkehr wird nur so lange nach ihrem Zeitpunkt noch begrüßt (z. B. wenn das Band erst kurz danach gezeichnet wird). */
269export const WELCOME_WINDOW_MS = 60_000
270
271/** Zahl der laufenden Subagenten zur Zeit `now`. */
272export function activeAgents(f: Facts, now: number): number {
273 return (f.agents ?? []).filter((t) => now - t < AGENT_STALE_MS).length
274}
275
276/** Engine-Stimmung zu den Fakten zur Zeit `now` (ms). */
277export function deriveMood(f: Facts, now: number): string {
278 if (f.ask) return f.askSince && now - f.askSince >= WAIT_ASK_LONG_MS ? 'waitUserLong' : 'waitUser'
279 const sk = f.sidekick && now - f.sidekick.at < SK_MAX_MS[f.sidekick.kind] ? f.sidekick.kind : null
280 // sidekick fragt Fynn (Dialog offen): wie eine Rückfrage
281 if (sk === 'stop') return 'sk_stop'
282 // Limit ausgeschöpft: Claude kann nicht arbeiten, das hat Vorrang vor allem außer einer offenen Rückfrage
283 const lim = limitState(f, now)
284 if (lim === 'week') return 'limit_week'
285 if (lim === 'five') return 'limit_5h'
286 if (lim === 'back' && !f.turnActive) return 'limit_back'
287 // Komprimieren hält alles an; danach geht es mit der Arbeit weiter
288 if (f.compactSince && now - f.compactSince < COMPACT_MAX_MS) return 'compact'
289 // sidekick baut einen neuen Chat bzw. hat ihn eben gestartet (der Turn darin beginnt sofort: die Freude geht kurz vor)
290 if (sk === 'handoff') return 'sk_handoff'
291 if (sk === 'fresh') return 'sk_fresh'
292 if (sk === 'check') return 'sk_check'
293 if (f.skillAt && now - f.skillAt < SKILL_MS) return 'skill'
294 const ownTool = f.tool && f.turnActive && f.tool.kind !== 'agent' ? f.tool : null
295 // Commits und Checks haben eigene Warte-Animationen: dort kein Wechsel ins allgemeine Warten
296 if (ownTool && ownTool.kind !== 'git' && ownTool.kind !== 'test') {
297 const age = now - ownTool.since
298 if (age >= WAIT_STRONG_MS) return 'wait60'
299 if (age >= WAIT_LONG_MS) return 'wait10'
300 }
301 const agents = activeAgents(f, now)
302 // Ein Subagent ist eben fertig: sein Helfer bringt ein Geschenk (einmalig, vor eigener Arbeit und Schwarm)
303 if (f.agentDoneAt && now - f.agentDoneAt < AGENT_DONE_MS) return 'agent_done'
304 if (ownTool) return WORK[ownTool.kind]
305 const linger = f.turnActive && !f.tool && f.lastTool && f.lastTool.kind !== 'agent' && now - f.lastTool.endedAt < TOOL_LINGER_MS
306 if (linger) return WORK[f.lastTool!.kind]
307 // Erfolgsserie erreicht eine Marke: einmal Pokal/Medaille (statt des normalen „fertig“)
308 // Kontext wird voll: wichtiger als Pokal und Fertig (Fynn sieht, dass bald komprimiert werden sollte)
309 if (!f.turnActive && f.ctxAt && now - f.ctxAt < ENDED_MS) return 'ctx_full'
310 if (!f.turnActive && f.streakAt && now - f.streakAt < ENDED_MS) return 'streak'
311 if (!f.turnActive && f.endedKind && now - f.endedAt < ENDED_MS) return f.endedKind === 'done' && (f.turnMs ?? 0) >= LONG_TURN_MS ? 'done_long' : f.endedKind
312 if (agents > 0) return 'work_agent'
313 if (f.turnActive) return WORK[f.tool?.kind ?? 'think']
314 if (f.typingAt && now - f.typingAt < TYPING_MS) return 'watching'
315 return 'idle'
316}
317hooks/desk.ts 351 lines1// clawd-buddy: Antrieb für die Desktop-App (rein, kein `$`).
2//
3// In der Desktop-App lädt der Client-Rahmen nicht (SPEC → Offene Punkte); ein `Svg` aus dem Hooks-Modul aber schon. Darum läuft die
4// Engine dort im Hooks-Modul. Sie zeichnet nicht mehr je Bild neu (0.4.2): Jede Neuzeichnung des Bands lässt die Desktop-App auch die
5// Zeilen neu anfordern, die andere Mods hooken (sidekick: UserMessage), und deren Hover-Leiste flackerte (Fynn, 2026-10-06). Stattdessen
6// rechnet `plan` die nächsten Sekunden voraus und liefert sie als ein SVG mit SMIL-Animation, das der Desktop als Bild zeichnet
7// (SvgProps ohne `isInteractive`, types:11934-11935, 11956-11957). Der Stand der Engine bleibt dabei unverändert
8// (Schnappschuss samt Zufall). `draw` zieht ihn beim nächsten Zeichnen nach dem Zeitplan der gezeigten Animation auf die Uhrzeit nach,
9// gibt also genau das Gezeigte wieder, und rechnet erst ab da mit den neuen Fakten weiter: Ein Ereignis wirkt ab seiner Zeichnung, nie rückwirkend.
10//
11// Bild statt Rahmen (0.6.6, Fynn: `/clawd flicker` blinkt in allen drei Abschnitten): Bis 0.6.5 lief das SVG mit `isInteractive` in einem
12// Rahmen ohne Skripte. Ein neues `source` lädt den Rahmen neu, er ist dabei ~50 ms leer (gemessen im Chromium der App), und die App lud
13// beim Wechsel auch den unveränderten zweiten Rahmen neu: Die Staffelübergabe aus 0.6.3 half nicht. Ein Bild zeigt beim Tausch das alte, bis
14// das neue fertig ist (gemessen: kein leerer Moment). Eine neue Animation gilt darum sofort. SMIL läuft auch im Bild der App, obwohl die
15// Typen es nur beim Rahmen nennen (types:11956-11957): Desktop-Abnahme durch Fynn am 2026-10-07 (SPEC, Nachtrag 0.6.6).
16// Der Zeitplan (`segs`, `acts`) bleibt in der Form aus 0.6.3: Seit 0.6.6 hat er nach jeder Zeichnung genau einen Abschnitt und keine
17// ausstehenden Eingriffe, das Nachziehen läuft also mit den Fakten der gezeigten Animation.
18//
19// Das Bild ist in senkrechte Kacheln geteilt, jede mit eigener Zeitliste: Begleiter, die nebeneinander blinzeln und werkeln, ergäben
20// als Ganzes sonst so viele verschiedene Bilder, dass die Animation nur wenige Sekunden fassen könnte.
21import { createEngine } from './engine.ts'
22import type { Engine } from './engine.ts'
23import { seededRng } from './capture.ts'
24import { ALL_CLIPS, ALL_PROPS } from './library.ts'
25import { WELCOME_WINDOW_MS, activeAgents, deriveMood, deriveTemper, specialDay } from './mood.ts'
26import type { Facts, Strain } from './mood.ts'
27import { H, PAL, POSES, TICK, W, compose } from './stage.ts'
28
29export type DeskOpts = { seed: number; nightStart: number; nightEnd: number; idleSeconds: number; reduced: boolean; flip: boolean; birthday?: string }
30
31export type Plan = {
32 /** SVG-Dokument; das erste Bild ist auch ohne SMIL sichtbar. */
33 source: string
34 /** Länge der Animation in Takten; danach bleibt das letzte Bild stehen. */
35 ticks: number
36 /** Kachelbilder darin und Takte, an denen sich etwas ändert. */
37 frames: number
38 changes: number
39}
40
41/** Eingriff per `/clawd`: Clip vorführen, Nickerchen, anstupsen. */
42export type Act = { play: string } | 'nap' | 'boop'
43
44export type Desk = {
45 engine: Engine
46 /**
47 * Zeichnen zur Uhrzeit `now` (ms): Stand nach dem Zeitplan der gezeigten Animation nachziehen, dann ab da mit den neuen Fakten
48 * vorausrechnen (Pixel = `scale` CSS-Pixel). Neues und vorgemerkte Eingriffe gelten sofort; die neuen Fakten gelten danach als gezeigt.
49 */
50 draw: (now: number, facts: Facts, strain: Strain, scale: number) => Plan
51 /** Stand auf `now` nachziehen, so wie die gezeigte Animation lief. */
52 catchUp: (now: number) => void
53 /** Eingriff für die nächste Zeichnung vormerken; er wirkt ab ihr. */
54 request: (a: Act) => void
55 /** Stand bis `now` nachziehen, Takt für Takt, mit festen Fakten; liefert die Zahl der Takte. Der erste Aufruf setzt nur die Uhr. */
56 advance: (now: number, facts: Facts, strain: Strain) => number
57 /** Die nächsten Takte ab dem Stand als animiertes SVG, mit festen Fakten; der Stand selbst bleibt, wie er ist. */
58 plan: (facts: Facts, strain: Strain, scale: number) => Plan
59 /**
60 * Ab wann (ms nach `now`) zeigten die Fakten `facts` etwas anderes als die gezeigte Animation (Stimmung oder Zahl der Helfer)?
61 * `Infinity`: bis zu ihrem Ende nichts; 0: sofort (noch nichts gezeigt, oder Fynn ist eben zurückgekommen).
62 */
63 divergence: (now: number, facts: Facts, strain: Strain) => number
64 /** Wie lange (ms ab `now`) die gezeigte Animation noch läuft. */
65 remaining: (now: number) => number
66}
67
68const NAP_TICKS = 3000 // 5 Minuten
69export const PLAN_TICKS = 400 // höchstens 30 s je Animation
70export const PLAN_CHARS = 85_000 // Bilder je Animation; Rest für Zeitlisten und Rahmen
71export const SOURCE_MAX = 131_072 // Svg.source höchstens (types:11939)
72const CATCH_UP = 2400 // höchstens 3 min nachziehen; länger verdeckt: die Zeit davor wird übersprungen
73const TILE = 10 // Kachelbreite in Bühnenpixeln
74const TILES = Math.ceil(W / TILE)
75
76type Shown = { id: number; from: number } // ab Takt `from` steht Kachelbild `id`
77/** Fakten für die Takte bis einschließlich Zählerstand `until` (D.n nach dem Takt). */
78type Seg = { until: number; facts: Facts; strain: Strain }
79/** Eingriff, der wirkt, sobald der Zählerstand `at` erreicht ist (vor dem Bild dazu). */
80type At = { at: number; act: Act }
81
82const hourOf = (ms: number): number => {
83 const d = new Date(ms)
84 return d.getHours() + d.getMinutes() / 60
85}
86
87export function createDesk(o: DeskOpts): Desk {
88 const rnd = seededRng(o.seed)
89 const engine = createEngine({
90 clips: ALL_CLIPS, props: ALL_PROPS, rng: rnd.next, nightStart: o.nightStart, nightEnd: o.nightEnd,
91 idleSeconds: o.idleSeconds, reduced: o.reduced,
92 })
93 // Zustand des Antriebs (neben der Engine); `at` = Uhrzeit des Stands, -1 = noch keiner
94 let D = { n: 0, mood: '', napUntil: -1, seenBack: 0, at: -1 }
95 let drawn: { facts: Facts; strain: Strain; at: number; ticks: number } | null = null // zuletzt gezeigte Animation: Fakten, Beginn, Länge
96 let segs: Seg[] = [] // Zeitplan der gezeigten Animation (leer: noch keine)
97 let acts: At[] = []
98 let queued: Act[] = [] // vorgemerkt für die nächste Zeichnung
99 engine.start()
100
101 function tick(now: number, facts: Facts, strain: Strain) {
102 if (D.n === D.napUntil) engine.set({ nightStart: o.nightStart, nightEnd: o.nightEnd, idleSeconds: o.idleSeconds })
103 if (D.n % 50 === 0 && D.n >= D.napUntil) engine.set({ hour: hourOf(now), special: specialDay(now, o.birthday ?? '') })
104 if (D.n % 10 === 0) {
105 const t = deriveTemper(strain, now)
106 engine.set({ temper: t.temper, tired: t.tired })
107 }
108 D.n++
109 const back = facts.backAt ?? 0
110 if (back !== D.seenBack) {
111 D.seenBack = back
112 if (back && now - back < WELCOME_WINDOW_MS) engine.welcome()
113 }
114 engine.set({ agents: activeAgents(facts, now) })
115 const m = deriveMood(facts, now)
116 if (m !== D.mood) {
117 D.mood = m
118 engine.setMood(m)
119 }
120 engine.tick()
121 }
122
123 function apply(a: Act) {
124 if (a === 'nap') {
125 // Eine Weile Nacht und kurzer Leerlauf, damit er einschläft
126 D.napUntil = D.n + NAP_TICKS
127 engine.set({ hour: 23.5, nightStart: 22, nightEnd: 7, idleSeconds: 1 })
128 } else if (a === 'boop') engine.click()
129 else engine.play(a.play)
130 // `simulate` zeichnet gleich danach; das Zeichnen kann Zufall verbrauchen (Blinzeln). Hier ebenso, damit das Nachziehen gleich läuft
131 // (Review 0.6.3, K9)
132 engine.render()
133 }
134
135 const segAt = (n: number): Seg => segs.find((x) => x.until >= n) ?? segs[segs.length - 1]
136 /** Ein Takt nach dem Zeitplan, danach die Eingriffe, die mit ihm fällig werden. */
137 function step(at: number) {
138 const g = segAt(D.n + 1)
139 tick(at, g.facts, g.strain)
140 for (const x of acts) if (x.at === D.n) apply(x.act)
141 }
142
143 const frame = (): string[] => tilesOf(engine.render().buf, o.flip)
144
145 function advanceBy(now: number, one: (at: number) => void): number {
146 if (D.at < 0 || now - D.at > CATCH_UP * TICK) D.at = D.at < 0 ? now : now - CATCH_UP * TICK
147 let k = 0
148 while (now - D.at >= TICK) {
149 D.at += TICK
150 one(D.at)
151 k++
152 }
153 return k
154 }
155
156 const advance = (now: number, facts: Facts, strain: Strain): number => advanceBy(now, (at) => tick(at, facts, strain))
157
158 /** Nachziehen nach dem Zeitplan der gezeigten Animation; erledigte Eingriffe fallen heraus. */
159 function follow(now: number) {
160 advanceBy(now, step)
161 acts = acts.filter((x) => x.at > D.n)
162 }
163
164 const save = () => ({ e: engine.save(), d: { ...D }, a: rnd.a })
165 function load(snap: ReturnType<typeof save>) {
166 engine.restore(snap.e)
167 D = snap.d
168 rnd.a = snap.a
169 }
170
171 /** Die nächsten Takte nach dem Zeitplan als animiertes SVG; der Stand selbst bleibt, wie er ist. */
172 function simulate(scale: number): Plan {
173 const snap = save()
174 const ids = Array.from({ length: TILES }, () => new Map<string, number>()) // Kachelbild → Nummer
175 const bodies: string[][] = Array.from({ length: TILES }, () => [])
176 const shown: Shown[][] = Array.from({ length: TILES }, () => [])
177 let chars = 0
178 let ticks = 0
179 const at = Math.max(D.at, 0)
180 for (let i = 0; i <= PLAN_TICKS; i++) {
181 if (i > 0) step(at + i * TICK)
182 else for (const x of acts) if (x.at === D.n) apply(x.act)
183 const f = frame()
184 // Platz reicht nicht mehr für die neuen Kachelbilder: die Animation endet vor diesem Takt (der erste passt immer)
185 const fresh = f.reduce((n, body, t) => n + (ids[t].has(body) ? 0 : body.length), 0)
186 if (i > 0 && chars + fresh > PLAN_CHARS) break
187 chars += fresh
188 f.forEach((body, t) => {
189 let id = ids[t].get(body)
190 if (id === undefined) {
191 id = bodies[t].length
192 ids[t].set(body, id)
193 bodies[t].push(body)
194 }
195 const s = shown[t]
196 if (!s.length || s[s.length - 1].id !== id) s.push({ id, from: i })
197 })
198 ticks = i
199 }
200 load(snap)
201 // Zu lang (viele Wechsel verlängern die Zeitlisten): die Animation halbieren, bis sie passt; ein einzelnes Bild passt immer
202 let p = build(bodies, shown, Math.max(ticks, 1), scale)
203 while (p.source.length > SOURCE_MAX && p.ticks > 1) {
204 const half = Math.floor(p.ticks / 2)
205 p = build(bodies, shown.map((s) => s.filter((x) => x.from < half)), half, scale)
206 }
207 return p
208 }
209
210 function plan(facts: Facts, strain: Strain, scale: number): Plan {
211 const keep = { segs, acts }
212 segs = [{ until: Infinity, facts, strain }]
213 acts = []
214 const p = simulate(scale)
215 segs = keep.segs
216 acts = keep.acts
217 return p
218 }
219
220 /** Neu zeichnen: nachziehen, Zeitplan fortschreiben, vorausrechnen, Fälliges anwenden. */
221 function redraw(now: number, facts: Facts, strain: Strain, scale: number): Plan {
222 if (segs.length) follow(now)
223 else advance(now, facts, strain)
224 const n = D.n
225 // Neue Fakten und vorgemerkte Eingriffe gelten ab jetzt
226 segs = [{ until: Infinity, facts, strain }]
227 acts = queued.map((act) => ({ at: n, act }))
228 queued = []
229 const p = simulate(scale)
230 for (const x of acts) if (x.at === n) apply(x.act)
231 acts = acts.filter((x) => x.at > n)
232 drawn = { facts, strain, at: now, ticks: p.ticks }
233 return p
234 }
235
236 return {
237 engine,
238 advance,
239 plan,
240 draw: redraw,
241 catchUp(now) {
242 if (segs.length) follow(now)
243 },
244 request(a) {
245 queued.push(a)
246 },
247 divergence(now, facts, strainNow) {
248 if (!drawn || (facts.backAt ?? 0) !== (drawn.facts.backAt ?? 0)) return 0
249 // Die Laune (Gewichte der Clips) ist mit den Fakten gezeichnet; hat sie sich geändert, gleich neu (Review 2, K1)
250 if (strainNow !== drawn.strain) return 0
251 const end = drawn.at + drawn.ticks * TICK
252 for (let t = now; t <= end; t += TICK) {
253 if (deriveMood(facts, t) !== deriveMood(drawn.facts, t) || activeAgents(facts, t) !== activeAgents(drawn.facts, t)) return t - now
254 }
255 return Infinity
256 },
257 remaining(now) {
258 return drawn ? Math.max(0, drawn.at + drawn.ticks * TICK - now) : 0
259 },
260 }
261}
262
263// Nur für den Rahmen (isInteractive, im Betrieb seit 0.6.6 nicht mehr, nur in Abschnitt 2 von `/clawd flicker`); im Bild wirkungslos.
264// Der Rahmen bekäme sonst einen weißen Grund: Chromium hinterlegt einen Rahmen deckend, wenn dessen Farbschema
265// (ohne Angabe hell) nicht zu dem der Seite passt, im Dark Mode also immer. `light dark` übernimmt das Schema der Seite.
266// Bettet der Rahmen das SVG in ein HTML-Dokument ein, hätte `body` 8 px Rand: das Bild rutschte nach rechts unten aus dem Kasten und
267// würde dort abgeschnitten. Ohne HTML-Hülle treffen die Regeln nichts.
268const CSS = ':root{color-scheme:light dark}html,body{margin:0;padding:0;overflow:hidden}body>svg{display:block}'
269const head = (scale: number): string =>
270 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W * scale}" height="${H * scale}" shape-rendering="crispEdges"><style>${CSS}</style>`
271
272/** Das Bild als Pfade je Kachel und Farbe; waagerechte Läufe gleicher Farbe werden Rechtecke (an Kachelgrenzen geteilt). */
273function tilesOf(buf: readonly (string | null)[], flip: boolean): string[] {
274 const runs: Map<string, string>[] = Array.from({ length: TILES }, () => new Map())
275 for (let y = 0; y < H; y++) {
276 let x = 0
277 while (x < W) {
278 const c = buf[y * W + x]
279 if (!c) {
280 x++
281 continue
282 }
283 let e = x + 1
284 while (e < W && buf[y * W + e] === c) e++
285 let a = flip ? W - e : x
286 const b = a + e - x
287 while (a < b) {
288 const t = Math.floor(a / TILE)
289 const end = Math.min(b, (t + 1) * TILE)
290 runs[t].set(c, `${runs[t].get(c) ?? ''}M${a} ${y}h${end - a}v1h-${end - a}z`)
291 a = end
292 }
293 x = e
294 }
295 }
296 return runs.map((m) => {
297 let out = ''
298 for (const [c, d] of m) out += `<path fill="${PAL[c]}" d="${d}"/>`
299 return out
300 })
301}
302
303// Ziffern 3 × 5 für die Testbilder (Zeilen von oben, je 3 Bit)
304const DIGITS: Record<number, number[]> = { 1: [2, 6, 2, 2, 7], 2: [7, 1, 7, 4, 7] }
305const GRAY = '#8a8a8a'
306
307/**
308 * Testbild für `/clawd flicker`: Clawd steht still, oben links die Nummer des Abschnitts. `nonce` ändert nur ein unsichtbares Rechteck:
309 * gleiches Bild, anderes `source`. Rechts neben der Nummer blinkt ein Punkt per SMIL (an/aus je 0,5 s): Blinkt er, läuft SMIL in dieser
310 * Darstellung.
311 */
312export function stillSource(scale: number, flip: boolean, digit: number, nonce: number): string {
313 const body = tilesOf(compose({ ...POSES.stand }, ALL_PROPS).buf, flip).join('')
314 let d = ''
315 ;(DIGITS[digit] ?? []).forEach((bits, y) => {
316 for (let x = 0; x < 3; x++) if (bits & (4 >> x)) d += `M${1 + x} ${1 + y}h1v1h-1z`
317 })
318 const pulse = `<path fill="${GRAY}" d="M6 3h2v2h-2z"><animate attributeName="visibility" calcMode="discrete" dur="1s" repeatCount="indefinite" values="visible;hidden" keyTimes="0;0.5"/></path>`
319 return `${head(scale)}<rect x="${nonce % 1000}" y="0" width="0" height="0"/><path fill="${GRAY}" d="${d}"/>${pulse}${body}</svg>`
320}
321
322/** SVG mit SMIL aus den Abfolgen je Kachel (Takt 0 bis `ticks`); nur die darin gezeigten, nicht leeren Kachelbilder kommen hinein. */
323function build(bodies: readonly string[][], shown: readonly Shown[][], ticks: number, scale: number): Plan {
324 const dur = `${((ticks * TICK) / 1000).toFixed(3)}s`
325 const kt = (i: number) => String(+(i / ticks).toFixed(5))
326 let groups = ''
327 let frames = 0
328 const changeAt = new Set<number>()
329 shown.forEach((seq, t) => {
330 for (const s of seq) if (s.from > 0) changeAt.add(s.from)
331 for (const id of new Set(seq.map((s) => s.id))) {
332 if (!bodies[t][id]) continue
333 frames++
334 // Sichtbar ab jedem Abschnitt dieses Bilds, verborgen ab dem nächsten anderen; das letzte bleibt am Ende stehen.
335 // Der erste Abschnitt beginnt bei Takt 0, also beginnt auch jede Liste dort.
336 const keys: [number, 'visible' | 'hidden'][] = []
337 for (const s of seq) {
338 const vis = s.id === id ? 'visible' : 'hidden'
339 if (!keys.length || keys[keys.length - 1][1] !== vis) keys.push([s.from, vis])
340 }
341 const anim = keys.length > 1
342 ? `<animate attributeName="visibility" calcMode="discrete" dur="${dur}" fill="freeze" values="${keys.map((k) => k[1]).join(';')}" keyTimes="${keys.map((k) => kt(k[0])).join(';')}"/>`
343 : ''
344 groups += `<g visibility="${keys[0][1]}">${anim}${bodies[t][id]}</g>`
345 }
346 })
347 return { source: `${head(scale)}${groups}</svg>`, ticks, frames, changes: changeAt.size }
348}
349
350export const DESK_TICK = TICK
351hooks/i18n.ts 356 lines1// clawd-buddy: Texte in zwei Sprachen (Einstellung `language`, Standard en; release/I18N.md). Rein, kein `$`.
2// Die Figur selbst zeigt keinen Text; übersetzt werden die Ausgaben von /clawd, der alt-Text des Desktop-Bilds und die Clip-Namen.
3// Clip-Bezeichnungen stehen deutsch in den Clip-Dateien (`label`), die englischen hier (CLIP_EN).
4
5export type Lang = 'en' | 'de'
6
7export const langOf = (v: unknown): Lang => (v === 'de' ? 'de' : 'en')
8
9/** Zahl mit fester Nachkommazahl: en `1.5`, de `1,5`. */
10export const num = (lang: Lang, n: number, digits = 1): string => {
11 const s = n.toFixed(digits)
12 return lang === 'de' ? s.replace('.', ',') : s
13}
14
15type Texts = {
16 description: string
17 help: string
18 on: string
19 off: string
20 demoUsage: string
21 demoNone: (arg: string) => string
22 demoPlays: (name: string, label: string) => string
23 demoMore: (names: string) => string
24 nap: string
25 unknown: (sub: string) => string
26 status: (o: { on: boolean; mood: string; temper: string; tired: number; annoy: number; desk: string }) => string
27 mood: { veryGrumpy: string; grumpy: string; great: string; good: string; even: string }
28 deskNone: string
29 desk: (o: { secs: string; draws: number; reused: number; perMin: string; planSecs: string; changes: string; kb: string; calcAvg: string; calcMax: string; drawAvg: string; drawMax: string; others: string }) => string
30 deskFresh: (o: { shape: number; shown: number; skipped: number }) => string
31 flicker: string
32 flickerNone: string
33 alt: string
34 /** Verweis hinter der Kurzhilfe bei einem unbekannten Argument (docs/HELP-SPEC.md §2) */
35 seeHelp: string
36 /** Texte der Hilfe-Tabelle `/clawd help` (docs/HELP-SPEC.md §5 „clawd-buddy 0.7.0“); Überschriften stehen in help.ts */
37 hx: {
38 intro: string
39 status: string
40 on: string
41 off: string
42 list: string
43 demo: string
44 nap: string
45 boop: string
46 flicker: string
47 help: string
48 click: string
49 arm: string
50 hold: string
51 mouse: { click: string; arm: string; hold: string }
52 buddy: string
53 reduced: string
54 setting: string
55 titles: { nightStart: string; nightEnd: string; idleSeconds: string; reducedMotion: string; side: string; birthday: string; language: string }
56 sides: { right: string; left: string }
57 birthdayOff: string
58 footerTerminal: string
59 footerDesktop: string
60 }
61}
62
63export const T: Readonly<Record<Lang, Texts>> = {
64 en: {
65 description: 'Control the mascot',
66 help: 'Commands: on | off | list | demo <animation> | nap | boop | status | flicker | help',
67 on: 'clawd-buddy: on',
68 off: 'clawd-buddy: off',
69 demoUsage: 'clawd demo <animation>. /clawd list shows all of them.',
70 demoNone: (arg) => `no animation "${arg}". /clawd list shows all of them.`,
71 demoPlays: (name, label) => `playing: ${name} (${label})`,
72 demoMore: (names) => `, more matches: ${names}`,
73 nap: 'clawd-buddy: getting sleepy …',
74 unknown: (sub) => `unknown: ${sub}.`,
75 status: (o) => `clawd-buddy: ${o.on ? 'on' : 'off'}, ${o.mood} (${o.temper}), tiredness ${o.tired}%, annoyance ${o.annoy}. ${o.desk}.`,
76 mood: { veryGrumpy: 'very grumpy', grumpy: 'grumpy', great: 'in a great mood', good: 'in a good mood', even: 'calm' },
77 deskNone: 'Desktop drawing: no data (only runs in the desktop app while the band is drawn)',
78 desk: (o) =>
79 `Desktop drawing, last ${o.secs} s: ${o.draws} draws (${o.perMin}/min), ${o.reused} times kept unchanged (no reload), animations avg ${o.planSecs} s with ${o.changes} frame changes, ` +
80 `${o.kb} kB; compute avg ${o.calcAvg} / max ${o.calcMax} ms, draw avg ${o.drawAvg} / max ${o.drawMax} ms (other mods avg ${o.others} ms)`,
81 deskFresh: (o) =>
82 `; redrawn: band layout changed ${o.shape}×, shown again or switched on ${o.shown}×; ${o.skipped} events without redraw (picture unchanged)`,
83 flicker:
84 'Flicker test, 20 s, watch Clawd (the number top left shows the part), he stands still and is redrawn every 2 s; the dot next to the number ' +
85 'blinks on its own (animation): 1 = as an image, as in normal operation now. 2 = in a frame, as up to 0.6.5 (for comparison). For each part: ' +
86 'does the dot blink, and does Clawd blink? Afterwards he continues normally.',
87 flickerNone: 'Flicker test only works in the desktop app while the band is shown.',
88 alt: 'Clawd, the mascot',
89 seeHelp: '→ /clawd help',
90 hx: {
91 intro: 'An animated pixel mascot above the prompt that reacts to Claude\'s work, waiting times and the time of day, with a mood of his own that builds up over the session.',
92 status: 'Status: on/off, mood, tiredness, annoyance; in the desktop app also drawing measurements',
93 on: 'Show Clawd (persists)',
94 off: 'Hide Clawd (persists)',
95 list: 'All animations by group',
96 demo: 'Play one animation (switches him on), e.g. type_laptop',
97 nap: 'Send him to sleep for about 5 minutes',
98 boop: 'Poke him',
99 flicker: 'Desktop app only: 20-second flicker test',
100 help: 'This help (also /clawd ?)',
101 click: 'Click on Clawd',
102 arm: 'Drag an arm',
103 hold: 'Hold the body ~0.7 s, then drag',
104 mouse: {
105 click: 'Terminal: giggles; many clicks annoy him (counted in status)',
106 arm: 'Terminal: the arm follows briefly and snaps back',
107 hold: 'Terminal: he hangs from the pointer, falls when you let go',
108 },
109 buddy: 'Buddy',
110 reduced: 'Reduced motion',
111 setting: 'setting',
112 titles: {
113 nightStart: 'Night starts (hour)',
114 nightEnd: 'Night ends (hour)',
115 idleSeconds: 'Idle time before pastimes (s)',
116 reducedMotion: 'Reduced motion',
117 side: 'Position in the band',
118 birthday: 'Birthday (DD.MM.)',
119 language: 'Language',
120 },
121 sides: { right: 'right', left: 'left' },
122 birthdayOff: 'off (empty)',
123 footerTerminal: 'Change settings: /plugin configure clawd-buddy · Turn the mod off: /plugin disable clawd-buddy',
124 footerDesktop: 'Turn the mod off: + → Plugins → Manage plugins · Change settings: /plugin configure clawd-buddy in a terminal',
125 },
126 },
127 de: {
128 description: 'Maskottchen steuern',
129 help: 'Befehle: on | off | list | demo <animation> | nap | boop | status | flicker | help',
130 on: 'clawd-buddy: an',
131 off: 'clawd-buddy: aus',
132 demoUsage: 'clawd demo <animation>. Mit /clawd list siehst du alle.',
133 demoNone: (arg) => `keine Animation „${arg}“. /clawd list zeigt alle.`,
134 demoPlays: (name, label) => `spielt: ${name} (${label})`,
135 demoMore: (names) => `, weitere Treffer: ${names}`,
136 nap: 'clawd-buddy: wird müde …',
137 unknown: (sub) => `unbekannt: ${sub}.`,
138 status: (o) => `clawd-buddy: ${o.on ? 'an' : 'aus'}, ${o.mood} (${o.temper}), Müdigkeit ${o.tired} %, Ärger ${o.annoy}. ${o.desk}.`,
139 mood: { veryGrumpy: 'sehr gereizt', grumpy: 'gereizt', great: 'bester Laune', good: 'gut gelaunt', even: 'ausgeglichen' },
140 deskNone: 'Desktop-Zeichnung: keine Daten (läuft nur im Desktop, während das Band gezeichnet wird)',
141 desk: (o) =>
142 `Desktop-Zeichnung, letzte ${o.secs} s: ${o.draws} Zeichnungen (${o.perMin}/min), ${o.reused}-mal unverändert weitergegeben (kein Neuladen), Animationen Ø ${o.planSecs} s mit ${o.changes} Bildwechseln, ` +
143 `${o.kb} kB; Rechnen Ø ${o.calcAvg} / max ${o.calcMax} ms, Zeichnen Ø ${o.drawAvg} / max ${o.drawMax} ms (davon andere Mods Ø ${o.others} ms)`,
144 deskFresh: (o) =>
145 `; neu gezeichnet: Aufbau des Bands geändert ${o.shape}×, wieder angezeigt oder eingeschaltet ${o.shown}×; ${o.skipped} Ereignisse ohne Neuzeichnung (Bild unverändert)`,
146 flicker:
147 'Flacker-Test, 20 s, schau auf Clawd (die Zahl oben links zeigt den Abschnitt), er steht still und wird alle 2 s neu gezeichnet; der Punkt neben der ' +
148 'Zahl blinkt von selbst (Animation): 1 = als Bild, wie jetzt im Betrieb. 2 = im Rahmen, wie bis 0.6.5 (zum Vergleich). Je Abschnitt: Blinkt der ' +
149 'Punkt, und blinkt Clawd? Danach geht es normal weiter.',
150 flickerNone: 'Der Flacker-Test geht nur in der Desktop-App, während das Band angezeigt wird.',
151 alt: 'Clawd, das Maskottchen',
152 seeHelp: '→ /clawd help',
153 hx: {
154 intro: 'Ein animiertes Pixel-Maskottchen über dem Prompt, das auf Claudes Arbeit, Wartezeiten und die Tageszeit reagiert, mit eigener Laune, die sich im Lauf der Sitzung aufbaut.',
155 status: 'Status: an/aus, Laune, Müdigkeit, Ärger; im Desktop auch Messwerte der Zeichnung',
156 on: 'Zeigt Clawd (bleibt gespeichert)',
157 off: 'Blendet Clawd aus (bleibt gespeichert)',
158 list: 'Zeigt alle Animationen nach Gruppen',
159 demo: 'Spielt eine Animation ab (schaltet ihn ein), z. B. type_laptop',
160 nap: 'Schickt ihn für etwa 5 Minuten schlafen',
161 boop: 'Stupst ihn an',
162 flicker: 'Nur Desktop-App: 20-s-Flacker-Test',
163 help: 'Zeigt diese Hilfe (auch /clawd ?)',
164 click: 'Klick auf Clawd',
165 arm: 'Arm ziehen',
166 hold: 'Körper ~0,7 s halten, dann ziehen',
167 mouse: {
168 click: 'Terminal: kichert; viele Klicks ärgern ihn (zählt in status)',
169 arm: 'Terminal: der Arm folgt kurz und schnappt zurück',
170 hold: 'Terminal: er hängt am Zeiger und fällt beim Loslassen',
171 },
172 buddy: 'Buddy',
173 reduced: 'Weniger Bewegung',
174 setting: 'Einstellung',
175 titles: {
176 nightStart: 'Nacht beginnt (Stunde)',
177 nightEnd: 'Nacht endet (Stunde)',
178 idleSeconds: 'Leerlauf bis Zeitvertreib (s)',
179 reducedMotion: 'Weniger Bewegung',
180 side: 'Position im Band',
181 birthday: 'Geburtstag (TT.MM.)',
182 language: 'Sprache',
183 },
184 sides: { right: 'rechts', left: 'links' },
185 birthdayOff: 'aus (leer)',
186 footerTerminal: 'Einstellungen ändern: /plugin configure clawd-buddy · Mod abschalten: /plugin disable clawd-buddy',
187 footerDesktop: 'Mod abschalten: + → Plugins → Manage plugins · Einstellungen ändern: im Terminal /plugin configure clawd-buddy',
188 },
189 },
190}
191
192/** Englische Bezeichnungen der Clips (die deutschen stehen als `label` an jedem Clip). */
193export const CLIP_EN: Readonly<Record<string, string>> = {
194 idle_breathe: 'Breathing & blinking',
195 idle_look: 'Looking around',
196 stretch: 'Stretching',
197 whistle: 'Whistling',
198 fly_chase: 'Chasing a fly',
199 pebble_kick: 'Kicking a pebble',
200 dance: 'Little dance',
201 juggle: 'Juggling',
202 stroll: 'Strolls',
203 smell_flower: 'Smells a flower',
204 dribble_ball: 'Bouncing a ball',
205 bubbles: 'Soap bubbles',
206 hop: 'Hopping',
207 grumble_kick: 'Kicks grumpily',
208 happy_hum: 'Hums happily',
209 nap_sitting: 'Nap while sitting',
210 night_candle: 'Tea light at night',
211 night_sheep: 'Counting sheep',
212 night_stars: 'Stargazing',
213 bday_cake: 'Birthday cake',
214 bday_party: 'Birthday party',
215 ny_fireworks: 'Fireworks',
216 ny_sparkler: 'Sparkler',
217 type_laptop: 'Types on the laptop',
218 type_fast: 'Types fast',
219 type_sleepy: 'Types sleepily (at night)',
220 read_scroll: 'Reads a scroll',
221 magnify: 'Magnifying glass',
222 terminal_watch: 'Watches the terminal',
223 shell_log: 'Watches the log window',
224 shell_spinner: 'Waits for the tablet',
225 press_buttons: 'Presses buttons',
226 globe_spin: 'Globe',
227 antenna: 'Antenna (web)',
228 wifi_router: 'Wi-Fi router',
229 think_dots: 'Thinking (…)',
230 think_tap: 'Pondering',
231 shell_keys: 'Types on a mini keyboard',
232 web_dove: 'Carrier pigeon',
233 type_grumpy: 'Types grumpily',
234 type_tired: 'Types tiredly',
235 think_sigh: 'Sighs while thinking',
236 think_happy: 'Thinks cheerfully',
237 think_chin: 'Rubs his chin',
238 think_scratch: 'Scratches his head',
239 think_pace: 'Paces thoughtfully',
240 think_look_up: 'Looks up thoughtfully',
241 think_tap_foot: 'Taps his foot while pondering',
242 git_graph: 'Watches the commit graph',
243 test_checklist: 'Ticks off a checklist',
244 git_save: 'Saves to a floppy disk',
245 git_push: 'Pushes up into the cloud',
246 test_tube: 'Shakes a test tube',
247 wave_helper: 'Waves to the helper',
248 high_five_helper: 'High five with the finished helper',
249 fist_bump_helper: 'Fist bump with the helper',
250 chat_helper: 'Chats with the helper',
251 hop_with_helper: 'Hops with the helper',
252 pat_helper: 'Pats the helper',
253 helper_report: 'Helper tells a story',
254 helper_busy: 'Helper is working',
255 helper_think: 'Helper is thinking',
256 helper_night_watch: 'Watches the helper sleepily',
257 helper_doze: 'Dozes next to the helper',
258 chat_second: 'Talks to the second helper',
259 nod_to_team: 'Nods to the team',
260 team_glance: 'Counts the team',
261 helper_gift: 'Helper brings a present',
262 handoff_note: 'Helper hands over a note',
263 handoff_stack: 'Helper hands over a stack of pages',
264 handoff_folder: 'Helper hands over a folder',
265 handoff_envelope: 'Helper hands over an envelope',
266 foot_tap: 'Foot tapping',
267 watch_check: 'Checks the clock',
268 sit_wait: 'Sits and waits',
269 lie_wait: 'Lies down and waits',
270 build_tower: 'Builds a little tower',
271 sand_glass: 'Hourglass',
272 walk_pace: 'Paces while waiting',
273 look_prompt: 'Looks at the prompt',
274 wave_question: 'Waves with a question mark',
275 sign_question: 'Holds up a question mark sign',
276 peek_prompt: 'Reads along while you type',
277 nod: 'Nods in agreement',
278 read_along: 'Reads along (line by line)',
279 take_notes: 'Takes notes',
280 rub_hands: 'Rubs his hands eagerly',
281 impatient_huff: 'Huffs impatiently',
282 knock_prompt: 'Knocks on the input',
283 big_wave_question: 'Waves big with a question mark',
284 alarm_nap: 'Dozes next to the alarm clock',
285 spin_top: 'Plays with a spinning top',
286 limit_annoyed: 'Annoyed (waiting for the limit)',
287 limit_sad: 'Sad (not allowed to work)',
288 suitcase_wait: 'Sits on packed bags and counts days',
289 fishing_wait: 'Sits with a fishing rod and waits',
290 sandglass_wait: 'Waits with the hourglass',
291 limit_garden: 'Watches the flower grow',
292 limit_paint: 'Paints a picture',
293 limit_back_jump: 'Back to work (jump for joy)',
294 limit_back_confetti: 'Back to work (confetti)',
295 celebrate_jump: 'Jump for joy',
296 sparkle_cheer: 'Cheers with sparkles',
297 shrug: 'Shrug',
298 sweat_drop: 'Drop of sweat',
299 facepalm: 'Facepalm',
300 stomp: 'Stomps in anger',
301 relief: 'Relieved',
302 victory_dance: 'Victory dance',
303 streak_trophy: 'Trophy (winning streak)',
304 streak_medal: 'Medal (winning streak)',
305 yawn: 'Yawning',
306 drowsy: 'Nodding off',
307 sleep: 'Sleeping (zZz)',
308 startled: 'Startled awake',
309 rub_eyes: 'Rubbing his eyes',
310 morning_stretch: 'Morning stretch',
311 coffee: 'Coffee',
312 morning_brush: 'Brushing teeth',
313 giggle: 'Giggling',
314 boop: 'Boop',
315 blush: 'Blushing',
316 grumpy: 'Annoyed',
317 sulk: 'Sulking',
318 held_wiggle: 'Lifted up (wriggles)',
319 dizzy: 'Dizzy',
320 sit_down: 'stand → sit',
321 stand_up: 'sit → stand',
322 lie_down: 'sit → lie',
323 get_up: 'lie → sit',
324 turn_away: 'turn away',
325 turn_back: 'turn back',
326 lift: 'stand → lifted',
327 drop: 'lifted → stand',
328 // sidekick (Schnittstelle zum Mod sidekick)
329 sk_read_check: 'Reads your message carefully',
330 sk_lens_check: 'Checks your message with a magnifier',
331 sk_hold_on: 'Raises a hand: hold on!',
332 sk_stop_sign: 'Holds up a stop sign',
333 sk_write_letter: 'Writes a handoff letter',
334 sk_open_letter: 'Opens the letter from the new chat',
335 // dünne Gruppen ergänzt (2026-10-06)
336 point_prompt: 'Points at the input',
337 bubble_question: 'Asks with a speech bubble',
338 limit_knit: 'Knits a scarf',
339 limit_sandcastle: 'Builds a sandcastle',
340 morning_jacks: 'Morning workout (jumping jacks)',
341 morning_water: 'Waters the flower',
342 // Komprimieren und Skill-Start
343 compact_press: 'Squashes a paper stack',
344 compact_box: 'Crumples paper into a box',
345 skill_book: 'Opens a book',
346 skill_tools: 'Grabs a wrench',
347 // Hinweise am Turn-Ende: Kontext fast voll, langer Turn geschafft
348 ctx_box: 'Box overflows (context almost full)',
349 ctx_stack: 'Wobbly paper stack (context almost full)',
350 long_phew: 'Phew, done (long turn)',
351 long_flag: 'Waves the finish flag (long turn)',
352}
353
354/** Bezeichnung eines Clips in der eingestellten Sprache. */
355export const clipLabel = (lang: Lang, c: { name: string; label: string }): string => (lang === 'de' ? c.label : (CLIP_EN[c.name] ?? c.name))
356hooks/clawdhelp.ts 77 lines1// clawd-buddy: Inhalt von `/clawd help` (docs/HELP-SPEC.md §5 „clawd-buddy 0.7.0“). Rein, kein `$`: Wortlisten des Parsers und der
2// Schnappschuss `HelpData`; die Zeichnung macht help.ts (Kopie von templates/help/help.ts).
3import { helpLabels } from './help.ts'
4import type { HelpData } from './help.ts'
5import { T } from './i18n.ts'
6import type { Lang } from './i18n.ts'
7
8/** Akzent der Hilfe: Theme-Key claude (Orange), passt sich hell und dunkel an (HELP-SPEC §4, geändert 2026-10-09; vorher #D77757). */
9export const ACCENT = 'claude'
10
11/** Unterbefehle, die `/clawd` versteht (register.ts nutzt die Liste; ein Test prüft jeden gegen die Hilfe). */
12export const SUBCOMMANDS = ['status', 'on', 'off', 'list', 'demo', 'nap', 'boop', 'flicker'] as const
13/** Wörter für die Hilfe, nur als einziges Wort, Groß-/Kleinschreibung egal (HELP-SPEC §2). */
14export const HELP_WORDS = ['help', '?'] as const
15
16/** Ist `args` ein Aufruf der Hilfe? Erstes Wort `help` oder `?`, sonst nichts. */
17export function isHelp(args: string): boolean {
18 const words = args.trim().split(/\s+/).filter(Boolean)
19 return words.length === 1 && (HELP_WORDS as readonly string[]).includes(words[0]!.toLowerCase())
20}
21
22/** Werte aus `options` (userConfig) und Zustand beim Aufruf; Standardwerte wie in plugin.json. */
23export type HelpInput = {
24 lang: Lang
25 enabled: boolean
26 nightStart: number
27 nightEnd: number
28 idleSeconds: number
29 reduced: boolean
30 side: 'right' | 'left'
31 birthday: string
32}
33
34export const DEFAULTS = { nightStart: 23, nightEnd: 6, idleSeconds: 45, reduced: false, side: 'right', birthday: '05.01.', lang: 'en' } as const
35
36export function clawdHelp(o: HelpInput): HelpData {
37 const t = T[o.lang]
38 const x = t.hx
39 const L = helpLabels(o.lang)
40 const onOff = (v: boolean) => (v ? L.on : L.off)
41 return {
42 mod: 'clawd-buddy',
43 lang: o.lang,
44 intro: x.intro,
45 commands: [
46 { cmd: '/clawd [status]', does: x.status },
47 { cmd: '/clawd on', does: x.on },
48 { cmd: '/clawd off', does: x.off },
49 { cmd: '/clawd list', does: x.list },
50 { cmd: '/clawd demo <animation>', does: x.demo },
51 { cmd: '/clawd nap', does: x.nap },
52 { cmd: '/clawd boop', does: x.boop },
53 { cmd: '/clawd flicker', does: x.flicker },
54 { cmd: '/clawd help', does: x.help },
55 ],
56 controls: [
57 { cmd: x.click, does: x.mouse.click },
58 { cmd: x.arm, does: x.mouse.arm },
59 { cmd: x.hold, does: x.mouse.hold },
60 ],
61 features: [
62 { name: x.buddy, state: { kind: o.enabled ? 'on' : 'off' }, toggle: o.enabled ? '/clawd off' : '/clawd on' },
63 { name: x.reduced, state: { kind: o.reduced ? 'on' : 'off' }, toggle: x.setting },
64 ],
65 settings: [
66 { title: x.titles.nightStart, value: String(o.nightStart), isDefault: o.nightStart === DEFAULTS.nightStart },
67 { title: x.titles.nightEnd, value: String(o.nightEnd), isDefault: o.nightEnd === DEFAULTS.nightEnd },
68 { title: x.titles.idleSeconds, value: String(o.idleSeconds), isDefault: o.idleSeconds === DEFAULTS.idleSeconds },
69 { title: x.titles.reducedMotion, value: onOff(o.reduced), isDefault: o.reduced === DEFAULTS.reduced },
70 { title: x.titles.side, value: x.sides[o.side], isDefault: o.side === DEFAULTS.side },
71 { title: x.titles.birthday, value: o.birthday || x.birthdayOff, isDefault: o.birthday === DEFAULTS.birthday },
72 { title: x.titles.language, value: o.lang, isDefault: o.lang === DEFAULTS.lang },
73 ],
74 footer: { terminal: x.footerTerminal, desktop: x.footerDesktop },
75 }
76}
77hooks/help.ts 205 lines1// help.ts: die gezeichnete Hilfe-Tabelle für `/<befehl> help` (docs/HELP-SPEC.md §3-§4). Allgemein gehalten: Eine Mod
2// liefert nur `HelpData` und ihre Akzentfarbe; Aufbau, Spalten, Farben der Schalter und die Markdown-Fassung stehen hier.
3// Vorlage für alle Mods: templates/help/ (README dort). Ohne `$`, nur Daten → Baum bzw. Text, deshalb ohne Engine testbar.
4//
5// Baum aus reinen Daten {type, props, children} (types RenderElement), nur Box und Text mit erlaubten Props. Desktop:
6// Spaltenbreiten nur als ganzzahlige Prozent, sonst verwirft er den ganzen Baum (cost-ledger 0.3.1). Unter 60 Spalten
7// stehen die Spalten untereinander.
8import type { RenderElement, RenderNode } from 'claude-code'
9
10export type HelpLang = 'en' | 'de'
11export type HelpSurface = 'terminal' | 'desktop'
12
13/** Eine Zeile unter BEFEHLE bzw. BEDIENUNG: Befehl oder Bedienelement (Akzentfarbe) und seine Wirkung. */
14export type HelpCommand = { cmd: string; does: string }
15/**
16 * Zustand einer Funktion: Schalter (`on` → „● an“ in `success`, `off` → „○ aus“ in `inactive`) oder ein Wert als Text, mit
17 * „(Standard)“, wenn `isDefault`. `text` ersetzt „an“/„aus“; bei `on` steht er dann in der normalen Schriftfarbe (lange
18 * Texte bleiben lesbar), nur der Punkt ist grün.
19 */
20export type HelpState = { kind: 'on' | 'off'; text?: string } | { kind: 'value'; text: string; isDefault?: boolean }
21/** Eine Zeile unter FUNKTIONEN; `toggle` ist der Befehl, der den Zustand ändert, sonst z. B. „Einstellung“ oder „nur Info“. */
22export type HelpFeature = { name: string; state: HelpState; toggle: string }
23/** Eine Zeile unter EINSTELLUNGEN: Titel des userConfig-Felds (übersetzt) und sein aktueller Wert. */
24export type HelpSetting = { title: string; value: string; isDefault?: boolean }
25/** Schnappschuss beim Aufruf von `help`; die Zeichnung schreibt sich danach nicht um (HELP-SPEC §3 Punkt 3). */
26export type HelpData = {
27 /** Name der Mod im Titel */
28 mod: string
29 lang: HelpLang
30 /** Ein Satz, was die Mod macht */
31 intro: string
32 commands: HelpCommand[]
33 /** Gedimmte Zeilen unter den Befehlen (z. B. Aliase) */
34 notes?: string[]
35 /** BEDIENUNG: nur, wenn es Klicks oder Tasten gibt */
36 controls?: HelpCommand[]
37 features: HelpFeature[]
38 settings: HelpSetting[]
39 /** Weg zum Ändern der Einstellungen und zum Abschalten der Mod; Terminal und Desktop brauchen verschiedene Wege */
40 footer: { terminal: string; desktop: string }
41}
42
43const LABELS = {
44 en: {
45 help: 'Help',
46 commands: 'COMMANDS',
47 controls: 'CONTROLS',
48 features: 'FEATURES',
49 status: 'STATUS',
50 toggle: 'TOGGLE',
51 settings: 'SETTINGS (/plugin)',
52 value: 'VALUE',
53 on: 'on',
54 off: 'off',
55 isDefault: '(default)',
56 },
57 de: {
58 help: 'Hilfe',
59 commands: 'BEFEHLE',
60 controls: 'BEDIENUNG',
61 features: 'FUNKTIONEN',
62 status: 'STATUS',
63 toggle: 'UMSCHALTEN',
64 settings: 'EINSTELLUNGEN (/plugin)',
65 value: 'WERT',
66 on: 'an',
67 off: 'aus',
68 isDefault: '(Standard)',
69 },
70} as const
71
72export function helpLabels(lang: HelpLang) {
73 return LABELS[lang]
74}
75
76type Props = Record<string, string | number | boolean>
77const el = (type: 'Box' | 'Text', props: Props, children: RenderNode[]): RenderElement => ({ type, props, children })
78const text = (s: string, props: Props = {}) => el('Text', props, [s])
79const dim = (s: string) => text(s, { dimColor: true })
80const row = (props: Props, kids: RenderNode[]) => el('Box', { flexDirection: 'row', ...props }, kids)
81const col = (props: Props, kids: RenderNode[]) => el('Box', { flexDirection: 'column', ...props }, kids)
82const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v))
83
84/** Zustand als Text (für die Markdown-Fassung und zum Messen der Spaltenbreite). */
85export function stateText(s: HelpState, lang: HelpLang): string {
86 const L = LABELS[lang]
87 if (s.kind === 'value') return s.isDefault ? `${s.text} ${L.isDefault}` : s.text
88 return `${s.kind === 'on' ? '●' : '○'} ${s.text ?? (s.kind === 'on' ? L.on : L.off)}`
89}
90
91/**
92 * Zustand gezeichnet: „● an“ in `success`, „○ aus“ in `inactive`; mit eigenem Text bei `on` nur der Punkt grün. Werte in
93 * der normalen Schriftfarbe, „(Standard)“ gedimmt.
94 */
95function stateNode(s: HelpState, lang: HelpLang): RenderElement {
96 const L = LABELS[lang]
97 if (s.kind === 'value') return el('Text', {}, [text(s.text), ...(s.isDefault ? [dim(` ${L.isDefault}`)] : [])])
98 const on = s.kind === 'on'
99 const label = s.text ?? (on ? L.on : L.off)
100 const labelProps: Props = on ? (s.text === undefined ? { color: 'success' } : {}) : { color: 'inactive' }
101 return el('Text', {}, [text(on ? '● ' : '○ ', { color: on ? 'success' : 'inactive' }), text(label, labelProps)])
102}
103
104/**
105 * Spalten einer Zeile. Terminal: feste Zellen, die letzte füllt den Rest. Desktop: ganzzahlige Prozent mit Summe 100
106 * (`share` = gewünschte Breite in Zeichen, daraus die Anteile).
107 */
108function columns(sf: HelpSurface, inner: number, widths: number[], kids: RenderNode[], props: Props = {}): RenderElement {
109 if (sf === 'desktop') {
110 const pct = widths.slice(0, -1).map((w) => clamp(Math.round((w / inner) * 100), 10, 60))
111 const rest = Math.max(10, 100 - pct.reduce((a, b) => a + b, 0))
112 const all = [...pct, rest]
113 // Summe genau 100: Überhang vom größten Anteil abziehen
114 const over = all.reduce((a, b) => a + b, 0) - 100
115 if (over > 0) all[all.indexOf(Math.max(...all))]! -= over
116 return row(props, kids.map((k, i) => el('Box', { width: `${all[i]}%`, paddingRight: 1 }, [k])))
117 }
118 return row(
119 props,
120 kids.map((k, i) => (i < kids.length - 1 ? el('Box', { width: widths[i]!, flexShrink: 0 }, [k]) : el('Box', { flexGrow: 1, flexShrink: 1 }, [k]))),
121 )
122}
123
124const heading = (s: string, accent: string) => text(s, { color: accent, bold: true })
125
126/**
127 * Der ganze Baum für eine `CommandOutput`-Zeile. `columns` ist `e.viewport?.columns` (begrenzt auf 30-140), `accent` die
128 * Akzentfarbe der Mod (Hex oder Theme-Key), nur für Titel, Überschriften und Befehle.
129 */
130export function helpTree(d: HelpData, columnsHint: number, surface: HelpSurface, accent: string): RenderElement {
131 const L = LABELS[d.lang]
132 const cols = clamp(columnsHint || 100, 30, 140)
133 const inner = cols - 4 // Rahmen und paddingX
134 const narrow = cols < 60
135 const kids: RenderNode[] = [heading(`${d.mod} · ${L.help}`, accent), text(d.intro)]
136
137 const commandRows = (title: string, list: HelpCommand[]) => {
138 if (!list.length) return
139 kids.push(el('Box', { marginTop: 1 }, [heading(title, accent)]))
140 const cmdW = clamp(Math.max(...list.map((c) => c.cmd.length)) + 2, 12, Math.floor(inner * 0.5))
141 for (const c of list)
142 kids.push(
143 narrow
144 ? col({}, [text(c.cmd, { color: accent }), el('Box', { paddingLeft: 2 }, [text(c.does)])])
145 : columns(surface, inner, [cmdW, inner - cmdW], [text(c.cmd, { color: accent }), text(c.does)]),
146 )
147 }
148 commandRows(L.commands, d.commands)
149 for (const n of d.notes ?? []) kids.push(dim(n))
150 commandRows(L.controls, d.controls ?? [])
151
152 if (d.features.length) {
153 const nameW = clamp(Math.max(L.features.length, ...d.features.map((f) => f.name.length)) + 2, 12, Math.floor(inner * 0.34))
154 const stateW = clamp(Math.max(L.status.length, ...d.features.map((f) => stateText(f.state, d.lang).length)) + 2, 10, Math.floor(inner * 0.4))
155 if (narrow) {
156 kids.push(el('Box', { marginTop: 1 }, [heading(L.features, accent)]))
157 // Zustand und Umschalten in einem Text, damit sie als ein Absatz umbrechen statt als zwei schmale Spalten
158 for (const f of d.features)
159 kids.push(col({}, [text(f.name), el('Box', { paddingLeft: 2 }, [el('Text', {}, [stateNode(f.state, d.lang), dim(` · ${f.toggle}`)])])]))
160 } else {
161 const widths = [nameW, stateW, inner - nameW - stateW]
162 kids.push(columns(surface, inner, widths, [heading(L.features, accent), heading(L.status, accent), heading(L.toggle, accent)], { marginTop: 1 }))
163 for (const f of d.features) kids.push(columns(surface, inner, widths, [text(f.name), stateNode(f.state, d.lang), dim(f.toggle)]))
164 }
165 }
166
167 if (d.settings.length) {
168 const titleW = clamp(Math.max(L.settings.length, ...d.settings.map((s) => s.title.length)) + 2, 12, Math.floor(inner * 0.5))
169 const value = (s: HelpSetting) => stateNode({ kind: 'value', text: s.value, isDefault: s.isDefault }, d.lang)
170 if (narrow) {
171 kids.push(el('Box', { marginTop: 1 }, [heading(L.settings, accent)]))
172 for (const s of d.settings) kids.push(col({}, [text(s.title), el('Box', { paddingLeft: 2 }, [value(s)])]))
173 } else {
174 const widths = [titleW, inner - titleW]
175 kids.push(columns(surface, inner, widths, [heading(L.settings, accent), heading(L.value, accent)], { marginTop: 1 }))
176 for (const s of d.settings) kids.push(columns(surface, inner, widths, [text(s.title), value(s)]))
177 }
178 }
179
180 kids.push(el('Box', { marginTop: 1 }, [dim(surface === 'desktop' ? d.footer.desktop : d.footer.terminal)]))
181 return col({ borderStyle: 'round', borderDimColor: true, paddingX: 1, width: '100%', key: `${d.mod}-help` }, kids)
182}
183
184/**
185 * Kompakte Markdown-Fassung: was Claude mitliest und was `-p`, das SDK und VS Code zeigen. `tag` (Kennung `#…`) steht in
186 * der ersten Zeile, darüber findet der Render-Hook den Schnappschuss. Fußzeile mit dem Terminal-Weg. Leerzeilen zwischen
187 * den Blöcken: Sonst hängt Markdown (CommonMark) alles nach einer Liste an deren letzten Punkt.
188 */
189export function helpMarkdown(d: HelpData, tag: string): string {
190 const L = LABELS[d.lang]
191 const lines = [`**${d.mod} · ${L.help}**${tag ? ` · ${tag}` : ''}`, '', d.intro]
192 const list = (title: string, items: HelpCommand[]) => {
193 if (!items.length) return
194 lines.push('', `**${title}**`, ...items.map((c) => `- \`${c.cmd}\`: ${c.does}`))
195 }
196 list(L.commands, d.commands)
197 if (d.notes?.length) lines.push('', ...d.notes)
198 list(L.controls, d.controls ?? [])
199 if (d.features.length) lines.push('', `**${L.features}:** ${d.features.map((f) => `${f.name} ${stateText(f.state, d.lang)} (${f.toggle})`).join(' · ')}`)
200 if (d.settings.length)
201 lines.push('', `**${L.settings}:** ${d.settings.map((s) => `${s.title} ${s.value}${s.isDefault ? ` ${L.isDefault}` : ''}`).join(' · ')}`)
202 lines.push('', d.footer.terminal)
203 return lines.join('\n')
204}
205hooks/stage.ts 355 lines1// clawd-buddy: Bühne, Posen und Zeichnen (reine Daten und Funktionen, kein `$`, kein DOM, keine Uhr).
2//
3// Die Bühne ist ein Pixelraster, 2 Pixelzeilen = 1 Terminalzeile (Halbblock ▀/▄). Die Figur (17×10 Pixel)
4// steht auf der untersten Pixelzeile; sie steht auf der Oberkante der Eingabebox. Alles, was unter die letzte Zeile
5// geschoben wird, verschwindet "hinter der Linie" (wird abgeschnitten): so kommen Gegenstände und Helfer ins Bild.
6// Die Bühne nutzt die ganze Leiste über der Eingabe (Fynn). Die Figur sitzt rechts; rechts bleibt Platz für Gegenstände neben ihr.
7// Ein- und Ausgang gibt es nur nach unten (hinter die Linie), nie über den linken, rechten oder oberen Rand (lint.ts prüft das).
8export const W = 100 // Bühne: Spalten
9export const H = 14 // Bühne: Pixelzeilen (7 Terminalzeilen)
10export const FX = 60 // Ursprung der Figur (linke Spalte) im Heimatplatz; rechts bleiben 23 Spalten Platz (gespiegelte Clips, wenn links Begleiter stehen), links 60
11export const FY = 4 // Ursprung der Figur (oberste Zeile) im Heimatplatz
12export const TICK = 75 // ms pro Tick: ~13 Bilder/s. Alle Clipdauern t zählen in Ticks.
13
14/** Palette der Bühne (Palettenzeichen → Farbe). O = Clawds Orange, K = Augen, Q = Schattenton der Vorderarme. */
15export const PAL: Readonly<Record<string, string>> = {
16 O: '#D77757', Q: '#A9573C', K: '#000000', W: '#F2F2F2', G: '#9A9A9A', D: '#4A4A4A',
17 B: '#5B9BD5', E: '#6CC070', Y: '#F2C94C', P: '#F09A8A', C: '#E9D8BE', R: '#7A4A2A',
18 L: '#8CCBF2', N: '#3E4C9A', T: '#2E2E2E', M: '#C9594B',
19 // Helfer (Subagenten): hellere Claude-Farbe für den Körper, dunklere für Arme und die hintere Reihe (Fynn, 2026-10-06: statt Blau)
20 A: '#EDA98A', F: '#B8664A',
21}
22
23/** Requisite: Palettenraster, '.' = durchsichtig. `effect`: kleiner Effekt (Funkeln, Note, …), der aus der Figur entsteht. */
24export type PropSprite = {
25 rows: readonly string[]
26 effect?: true
27 /** Schrift/Zeichen/Uhr: beim Spiegeln nur den Platz wechseln, das Bild selbst nicht umdrehen (ein „?“ bleibt ein „?“). */
28 noFlip?: true
29 /** Liegt vor den Händen vor dem Körper (Vorderarme): z. B. Jonglierbälle, die vor den Händen fliegen. Sonst decken die Hände Gegenstände. */
30 front?: true
31 /**
32 * Bild für die gespiegelte Figur (wird dann wie jedes andere gespiegelt): für Gegenstände, deren Form mitgespiegelt werden muss, deren Zeichen
33 * darin aber lesbar bleiben sollen (Denkblase mit „?“: die Spur hängt an der Figur). Darin steht das Zeichen seitenverkehrt.
34 */
35 mirrorRows?: readonly string[]
36 /**
37 * Verlängerung der Hand (Schattenton wie die Vorderarme), z. B. die schreibende Hand auf dem Blatt: gilt in den Prüfungen als Effekt
38 * an der Figur, darf aber auf dem Gegenstand liegen, den sie hält (anim.test „Effekte überlappen sich nie“).
39 */
40 hand?: true
41}
42/** Verweis auf eine Requisite im Frame: [Name, x, y, 'g'?]. Ohne 'g' relativ zur Figur, mit 'g' relativ zum Heimatplatz. */
43export type PropRef = readonly [name: string, x: number, y: number, flag?: 'g' | 'G']
44/**
45 * Arm der Figur: Name (`down`, `up1`, `raise` …), **Blockarm** `{ out, y, h? }` oder gestreckter Arm `{ to: [x, y] }`.
46 * Blockarm = achsenparalleles Rechteck am Körper (wie die Armstummel des echten Clawd): `out` Pixel lang (2 = Stummel), Oberkante `y`
47 * Pixel unter der Körperoberkante, `h` Pixel dick (Standard 2). `y` darf bis 9 gehen und darüber (dann steckt die Hand hinter der Linie).
48 * Der gestreckte Arm (`to`, dünn, diagonal) ist nur für das Ziehen mit der Maus gedacht.
49 */
50export type ArmSpec = string | { out: number; y: number; h?: number } | { to: readonly [number, number] }
51
52export type Pose = {
53 fx: number
54 fy: number
55 by: number
56 squash: number
57 legs: string
58 armL: ArmSpec
59 armR: ArmSpec
60 eyes: string
61 look: readonly [number, number]
62 mouth: string | null
63 blush: boolean
64 back: boolean
65 props: readonly PropRef[]
66 /** Ganze Szene gespiegelt (um die Mitte der Figur), siehe Engine: Clips laufen zufällig links- oder rechtsherum. */
67 mirror?: boolean
68 /**
69 * Gesicht seitlich versetzt (Pixel, + = nach rechts) für die 3/4-Drehung: Augen, Mund und Wangen rücken zur zugewandten Seite,
70 * wie im Laptop-GIF (die abgewandte Körperseite liegt dann im Schatten). Unabhängig von `look` (Pupillen) und nicht begrenzt.
71 */
72 face?: number
73}
74
75/** Kanonische Posen: Ein- und Ausstiegspunkte aller Clips. */
76export const DEF: Pose = {
77 fx: 0, fy: 0, by: 0, squash: 0, legs: 'stand', armL: 'down', armR: 'down', eyes: 'open',
78 look: [0, 0], mouth: null, blush: false, back: false, props: [],
79}
80export const POSES: Readonly<Record<string, Pose>> = {
81 stand: { ...DEF },
82 sit: { ...DEF, by: 2 },
83 lie: { ...DEF, by: 2, squash: 2, armL: 'out', armR: 'out', eyes: 'closed' },
84 back: { ...DEF, back: true, eyes: 'none' },
85 held: { ...DEF, fy: -3, legs: 'stepA', eyes: 'wide' },
86}
87export const POSE_NAMES: Readonly<Record<string, string>> = {
88 stand: 'stehen', sit: 'sitzen', lie: 'liegen', back: 'abgewandt', held: 'hochgehoben',
89}
90
91/** Arme: Pixel des linken Arms relativ zur Körperoberkante; rechts gespiegelt (x → 16−x). */
92export const ARMS: Readonly<Record<string, readonly (readonly [number, number])[]>> = {
93 down: [[0, 4], [1, 4], [0, 5], [1, 5]],
94 up1: [[0, 3], [1, 3], [0, 4], [1, 4]],
95 up2: [[0, 2], [1, 2], [0, 3], [1, 3]],
96 raise: [[0, 0], [1, 0], [0, 1], [1, 1]],
97 wave1: [[-1, 0], [0, 0], [0, 1], [1, 1]],
98 wave2: [[0, -1], [1, -1], [0, 0], [1, 0]],
99 out: [[-2, 4], [-1, 4], [0, 4], [1, 4], [-2, 5], [-1, 5], [0, 5], [1, 5]],
100 hide: [],
101}
102/**
103 * Arme vor dem Körper (Schattenfarbe Q). Wie alle Hände Blöcke, 2 Pixel dick (Fynn: "die Hände sollten blockig wirken", ein Stil für alles),
104 * nie dünne Striche. Links angegeben, rechts gespiegelt (x → 16−x).
105 */
106export const FRONT: Readonly<Record<string, readonly (readonly [number, number])[]>> = {
107 type: [[3, 4], [4, 4], [5, 4], [3, 5], [4, 5], [5, 5]],
108 typeUp: [[3, 3], [4, 3], [5, 3], [3, 4], [4, 4], [5, 4]],
109 hold: [[3, 4], [4, 4], [3, 5], [4, 5]],
110 /** Hält etwas unten vor dem Bauch (Buch, Zeitung) an den unteren Ecken. */
111 holdLow: [[3, 6], [4, 6], [3, 7], [4, 7]],
112 holdUp: [[3, 3], [4, 3], [3, 4], [4, 4]],
113 chin: [[5, 5], [6, 5], [7, 5], [5, 6], [6, 6], [7, 6], [7, 4], [8, 4]],
114 chinUp: [[5, 4], [6, 4], [7, 4], [5, 5], [6, 5], [7, 5], [7, 3], [8, 3]],
115 rub: [[3, 3], [4, 3], [5, 3], [3, 4], [4, 4], [5, 4]],
116 rubUp: [[3, 2], [4, 2], [5, 2], [3, 3], [4, 3], [5, 3]],
117}
118// Verschränkte Arme liegen unter den Augen (Zeilen 4–6), damit kein Auge verdeckt wird.
119export const CROSS_L: readonly (readonly [number, number])[] = [
120 [3, 5], [4, 5], [5, 5], [6, 5], [7, 5], [8, 5], [9, 5], [3, 6], [4, 6], [5, 6], [6, 6], [7, 6], [8, 6], [9, 6],
121]
122export const CROSS_R: readonly (readonly [number, number])[] = [
123 [7, 4], [8, 4], [9, 4], [10, 4], [11, 4], [12, 4], [13, 4], [7, 5], [8, 5], [9, 5], [10, 5], [11, 5], [12, 5], [13, 5],
124]
125
126export const LEGS: Readonly<Record<string, readonly (readonly [number, number])[]>> = {
127 stand: [[2, 8], [2, 9], [4, 8], [4, 9], [12, 8], [12, 9], [14, 8], [14, 9]],
128 stepA: [[2, 8], [4, 8], [4, 9], [12, 8], [14, 8], [14, 9]],
129 stepB: [[2, 8], [2, 9], [4, 8], [12, 8], [12, 9], [14, 8]],
130 tuck: [[2, 8], [4, 8], [12, 8], [14, 8]],
131 kickR: [[2, 8], [2, 9], [4, 8], [4, 9], [12, 8], [12, 9], [14, 8], [15, 9]],
132 kickL: [[1, 9], [2, 8], [4, 8], [4, 9], [12, 8], [12, 9], [14, 8], [14, 9]],
133 tapR: [[2, 8], [2, 9], [4, 8], [4, 9], [12, 8], [12, 9], [14, 8]],
134 spread: [[1, 8], [1, 9], [4, 8], [4, 9], [12, 8], [12, 9], [15, 8], [15, 9]],
135 none: [],
136}
137
138/** Augen: relativ zum Anker (links 4|top+2, rechts 12|top+2); rechts gespiegelt. `wink`: nur das linke Auge zu. */
139export const EYES: Readonly<Record<string, readonly (readonly [number, number])[]>> = {
140 open: [[0, 0], [0, 1]],
141 closed: [[-1, 1], [0, 1]],
142 half: [[0, 1]],
143 happy: [[-1, 1], [0, 0], [1, 1]],
144 angry: [[0, 0], [0, 1], [1, -1]],
145 sad: [[0, 0], [0, 1], [-1, -1]],
146 wide: [[0, 0], [0, 1], [1, 0], [1, 1]],
147 dizzy: [[-1, -1], [1, -1], [0, 0], [-1, 1], [1, 1]],
148 tight: [[-1, 0], [0, 1], [-1, 2]],
149 none: [],
150}
151/** Einseitige Augen (nur links bzw. nur rechts gezeichnet), z. B. Zwinkern beim Kramen. */
152export const EYES_ONE: Readonly<Record<string, { left: string; right: string }>> = {
153 winkR: { left: 'open', right: 'closed' }, // rechtes Auge zu
154 winkL: { left: 'closed', right: 'open' }, // linkes Auge zu
155}
156export const LOOKABLE_EYES: ReadonlySet<string> = new Set(['open', 'wide', 'half', 'angry', 'sad', 'winkR', 'winkL'])
157export const MOUTH: Readonly<Record<string, readonly (readonly [number, number])[]>> = {
158 o: [[8, 5]],
159 /** Gespitzter Mund beim Pfeifen (wird als einziger gezeichnet): 1 × 2 Pixel unter der Augenmitte. */
160 whistle: [[8, 5], [8, 6]],
161 yawn: [[8, 4], [8, 5]],
162 yawnBig: [[7, 4], [8, 4], [9, 4], [7, 5], [8, 5], [9, 5]],
163 smile: [[7, 4], [8, 5], [9, 4]],
164 flat: [[7, 5], [8, 5], [9, 5]],
165}
166
167/** Welche Pose-Felder die Figur "strukturell" verändern (sichere Frames, Glättung). */
168export const STRUCT = ['fx', 'fy', 'by', 'squash', 'back'] as const
169export const NUM = ['fx', 'fy', 'by', 'squash'] as const
170
171/** Tabelle der Requisiten; wird von props.ts befüllt (kein Import-Kreis: stage.ts kennt nur die Referenz). */
172export type PropTable = Readonly<Record<string, PropSprite>>
173
174export type Composed = {
175 /** Palettenzeichen je Pixel oder null. */
176 buf: (string | null)[]
177 /** Besitzer je Pixel: 'body' | 'armL' | 'armR' | 'prop:<name>' | null (für Maus und Prüfung). */
178 hit: (string | null)[]
179}
180
181const scaled = new Map<string, PropSprite | undefined>()
182/**
183 * Requisite nach Namen. `name@NN` ist dieselbe Requisite auf NN % verkleinert (nächster Nachbar): So wächst ein Gegenstand aus der
184 * Tasche heraus und schrumpft beim Wegpacken wieder hinein (Fynn: "der Laptop wird kleiner und verschwindet").
185 */
186export function propSprite(props: PropTable, name: string): PropSprite | undefined {
187 const at = name.indexOf('@')
188 if (at < 0) return props[name]
189 const key = name
190 if (scaled.has(key)) return scaled.get(key)
191 const src = props[name.slice(0, at)]
192 const f = Number(name.slice(at + 1)) / 100
193 let out: PropSprite | undefined
194 if (src && f > 0) {
195 const shrink = (from: readonly string[]): string[] => {
196 const h = from.length
197 const w = Math.max(...from.map((r) => r.length))
198 const nh = Math.max(1, Math.round(h * f))
199 const nw = Math.max(1, Math.round(w * f))
200 const rows: string[] = []
201 for (let j = 0; j < nh; j++) {
202 let row = ''
203 for (let i = 0; i < nw; i++) row += from[Math.floor(((j + 0.5) * h) / nh)][Math.floor(((i + 0.5) * w) / nw)] ?? '.'
204 rows.push(row)
205 }
206 return rows
207 }
208 out = {
209 rows: shrink(src.rows),
210 ...(src.effect ? { effect: true as const } : {}),
211 ...(src.noFlip ? { noFlip: true as const } : {}),
212 ...(src.front ? { front: true as const } : {}),
213 ...(src.mirrorRows ? { mirrorRows: shrink(src.mirrorRows) } : {}),
214 }
215 }
216 scaled.set(key, out)
217 return out
218}
219
220/** Pose → Pixelpuffer (+ Trefferkarte). Alles außerhalb der Bühne, auch unter der Linie, wird abgeschnitten. */
221export function compose(p: Pose, props: PropTable): Composed {
222 const buf: (string | null)[] = new Array(W * H).fill(null)
223 const hit: (string | null)[] = new Array(W * H).fill(null)
224 const ox = FX + Math.round(p.fx)
225 const oy = FY + Math.round(p.fy)
226 const top = p.by + p.squash
227 const bot = 7 + p.by
228 // Spiegeln um die Mitte des Heimatplatzes (Spalte FX+8), nicht um die laufende Figur: sonst liefe er gespiegelt weiter in die alte Richtung und die
229 // Gegenstände am Boden kämen ihm entgegen (Fynn, 2026-10-06: „die Blume kommt näher an ihn ran“). Linker und rechter Arm tauschen ihre Trefferkennung.
230 const mx = (x: number) => (p.mirror ? 2 * (FX + 8) - x : x)
231 const mt = (tag: string | null) => (p.mirror && tag === 'armL' ? 'armR' : p.mirror && tag === 'armR' ? 'armL' : tag)
232 const set = (x0: number, y: number, c: string | undefined, tag: string | null) => {
233 const x = mx(x0)
234 if (x < 0 || y < 0 || x >= W || y >= H || !c) return
235 buf[y * W + x] = c
236 if (tag) hit[y * W + x] = mt(tag)
237 }
238 const eyePx = new Set<number>()
239 for (const [x, y] of LEGS[p.legs] || []) set(ox + x, oy + y, 'O', 'body')
240 for (let y = top; y <= bot; y++) for (let x = 2; x <= 14; x++) set(ox + x, oy + y, 'O', 'body')
241 const sideArm = (spec: ArmSpec, right: boolean) => {
242 const tag = right ? 'armR' : 'armL'
243 if (typeof spec === 'object') {
244 if ('to' in spec) return stretchArm(spec.to, right, top, ox, oy, set)
245 // Blockarm: Rechteck am Körperrand (Körper Spalte 2–14), `out` Pixel nach außen
246 for (let j = 0; j < (spec.h ?? 2); j++) {
247 for (let i = 0; i < spec.out; i++) set(ox + (right ? 14 + 1 + i : 1 - i), oy + top + spec.y + j, 'O', tag)
248 }
249 return
250 }
251 const pts = ARMS[spec]
252 if (!pts) return
253 for (const [x, y] of pts) set(ox + (right ? 16 - x : x), oy + top + y, 'O', tag)
254 }
255 sideArm(p.armL, false)
256 sideArm(p.armR, true)
257 if (!p.back) {
258 // Seitlicher Blick höchstens 1 Pixel: Die Pupille bleibt eine Spalte vom Körperrand weg (Fynn: vor dunklem Hintergrund
259 // wirkt ein Auge am Rand wie abgeschnitten).
260 const lk = LOOKABLE_EYES.has(p.eyes) ? [Math.max(-1, Math.min(1, p.look[0])), p.look[1]] : [0, 0]
261 const one = EYES_ONE[p.eyes]
262 const fc = Math.round(p.face ?? 0)
263 const drawEye = (name: string, right: boolean) => {
264 for (const [dx, dy] of EYES[name] || []) {
265 const x = (right ? 12 - dx : 4 + dx) + fc
266 set(ox + x + lk[0], oy + top + 2 + dy + lk[1], 'K', null)
267 eyePx.add((oy + top + 2 + dy + lk[1]) * W + mx(ox + x + lk[0]))
268 }
269 }
270 if (one) {
271 drawEye(one.left, false)
272 drawEye(one.right, true)
273 } else {
274 drawEye(p.eyes, false)
275 drawEye(p.eyes, true)
276 }
277 // Kein Mund (Fynn: ohne wirkt er stimmiger als mit einem, der zwischendurch auftaucht). `mouth` bleibt in den Clips stehen,
278 // wird aber nicht gezeichnet; die Formen in MOUTH bleiben für eine spätere Wiederaufnahme. Einzige Ausnahme: der gespitzte Mund beim
279 // Pfeifen, ein schmales Rechteck in der Mitte (Fynn, 2026-10-06: „nur einen, der auch rechteckig ist“).
280 if (p.mouth === 'whistle') for (const [x, y] of MOUTH.whistle) set(ox + x + fc, oy + top + y, 'K', null)
281 if (p.blush) {
282 set(ox + 3 + fc, oy + top + 4, 'P', null)
283 set(ox + 13 + fc, oy + top + 4, 'P', null)
284 }
285 }
286 const drawProp = (pr: PropRef, front: boolean): void => {
287 const [name, px, py, flag] = pr
288 const s = propSprite(props, name)
289 if (!s || !!s.front !== front) return
290 const bx = flag ? FX + px : ox + px
291 const by = flag ? FY + py : oy + py + p.by
292 // 'G': fest auf der Bühne, wird beim Spiegeln nicht mitgespiegelt (Begleiter der Subagenten bleiben links)
293 const put = flag === 'G' && p.mirror ? (x: number, y: number, c: string, tag: string) => set(mx(x), y, c, tag) : set
294 // Nicht spiegelbar (Schrift, Zeichen, Uhren): gespiegelt wird nur die Lage, die Spalten laufen rückwärts, damit das Bild gleich bleibt
295 const keep = !!s.noFlip && !!p.mirror && flag !== 'G'
296 const rows = p.mirror && flag !== 'G' && s.mirrorRows && !s.noFlip ? s.mirrorRows : s.rows
297 const w = Math.max(...rows.map((r) => r.length))
298 rows.forEach((row, ry) => {
299 for (let rx = 0; rx < row.length; rx++) if (row[rx] !== '.') put(keep ? bx + w - 1 - rx : bx + rx, by + ry, row[rx], 'prop:' + name)
300 })
301 }
302 for (const pr of p.props) drawProp(pr, false)
303 // Hände vor dem Körper gehen nie über die Augen (Fynn), außer beim Augenreiben (rub*), wo genau das gemeint ist: Berührt ein Vorderarm
304 // ein Auge (auch nur daneben oder darunter), rutscht er als Ganzes bis zu 3 Pixel nach unten; nur wenn das nicht reicht, bleibt dort eine Lücke.
305 const touchesEye = (x: number, y: number): boolean =>
306 [[0, 0], [1, 0], [-1, 0], [0, 1], [0, -1]].some(([dx, dy]) => eyePx.has((y + dy) * W + mx(x + dx)))
307 const frontArm = (spec: ArmSpec, right: boolean) => {
308 const tag = right ? 'armR' : 'armL'
309 let pts: (readonly [number, number])[]
310 if (spec === 'cross') pts = (right ? CROSS_R : CROSS_L).map(([x, y]) => [ox + x, oy + top + y] as const)
311 else if (typeof spec === 'object' || !FRONT[spec]) return
312 else pts = FRONT[spec].map(([x, y]) => [ox + (right ? 16 - x : x), oy + top + y] as const)
313 if (typeof spec === 'string' && spec.startsWith('rub')) {
314 for (const [x, y] of pts) set(x, y, 'Q', tag)
315 return
316 }
317 let shift = 0
318 while (shift < 3 && pts.some(([x, y]) => touchesEye(x, y + shift))) shift++
319 for (const [x, y] of pts) if (!eyePx.has((y + shift) * W + mx(x))) set(x, y + shift, 'Q', tag)
320 }
321 frontArm(p.armL, false)
322 frontArm(p.armR, true)
323 for (const pr of p.props) drawProp(pr, true)
324 return { buf, hit }
325}
326
327/** Gestreckter Arm: Schulter → Ziel (Figurkoordinaten), 2 Pixel dick, Länge höchstens 9. */
328function stretchArm(
329 to: readonly [number, number],
330 right: boolean,
331 top: number,
332 ox: number,
333 oy: number,
334 set: (x: number, y: number, c: string | undefined, tag: string | null) => void,
335) {
336 const sx = right ? 15 : 1
337 const sy = top + 4.5
338 // Kurz halten (Fynn: lange Arme wirken unrealistisch) und kaum nach unten greifen.
339 let dx = to[0] - sx
340 let dy = Math.min(2, to[1] - sy)
341 const len = Math.hypot(dx, dy) || 1
342 const max = 5
343 if (len > max) {
344 dx = (dx / len) * max
345 dy = (dy / len) * max
346 }
347 const steps = Math.max(1, Math.ceil(Math.hypot(dx, dy)))
348 for (let i = 0; i <= steps; i++) {
349 const x = Math.round(sx + (dx * i) / steps)
350 const y = Math.round(sy + (dy * i) / steps - 0.5)
351 set(ox + x, oy + y, 'O', right ? 'armR' : 'armL')
352 set(ox + x, oy + y + 1, 'O', right ? 'armR' : 'armL')
353 }
354}
355hooks/buddy.ts 235 lines1// clawd-buddy: Client-Modul (läuft im Zeichen-Thread, siehe types:1225-1431). Hier lebt die Animation: Engine, Bildtakt, Maus.
2//
3// Das Hooks-Modul reicht nur Fakten als `props` herein (mood.ts → Facts, Uhrzeit, Einstellungen). Der Client leitet daraus die
4// Stimmung ab, treibt die Engine mit `surface.every(TICK)` (75 ms) und zeichnet das Bild in Halbblock-Auflösung (`▀` mit color = obere,
5// backgroundColor = untere Pixelzeile, 2 Pixel pro Zelle). Keine Arbeit pro Frame außer Engine-Tick und Zeichnen; kein `setState` im Render.
6import type { ClientPointerEvent, ClientSurface } from 'claude-code'
7import { createEngine } from './engine.ts'
8import type { Engine } from './engine.ts'
9import { ALL_CLIPS, ALL_PROPS } from './library.ts'
10import { WELCOME_WINDOW_MS, activeAgents, deriveMood, deriveTemper, specialDay } from './mood.ts'
11import type { Facts, Strain } from './mood.ts'
12import { mulberry32 } from './capture.ts'
13import { H, PAL, TICK, W } from './stage.ts'
14
15export type BuddyProps = {
16 /** Spalten und Zeilen, die das Band hergibt (bodyColumns, maxRows). */
17 columns: number
18 maxRows: number
19 /** Uhrzeit des Hooks-Moduls beim Zeichnen (ms) und Einstellungen. */
20 now: number
21 nightStart: number
22 nightEnd: number
23 idleSeconds: number
24 reduced: boolean
25 /** Spiegeln (Platz links im Band). */
26 flip: boolean
27 seed: number
28 facts: Facts
29 /** Geburtstag "TT.MM." (userConfig `birthday`) für besondere Tage. */
30 birthday?: string
31 /** Rohdaten der Laune (mood.ts → deriveTemper), auf der Uhr des Clients ausgewertet. */
32 strain?: Strain
33 /** `/clawd demo <clip>`, `/clawd nap`, `/clawd boop`: ein neuer Wert löst die Aktion einmal aus. */
34 demo: string
35 demoN: number
36 napN: number
37 boopN: number
38 /** Ärger aus dem Speicher (nur beim Start gelesen). */
39 annoy: number
40}
41
42type Ctl = {
43 props: BuddyProps
44 ticks: number
45 /** Synchronpunkt der Uhr: Hooks-Zeit `baseNow` bei Tick `baseTick` und echter Uhr `basePerf`. */
46 baseNow: number
47 baseTick: number
48 basePerf: number
49 mood: string
50 seenDemo: number
51 seenNap: number
52 seenBoop: number
53 lastAnnoy: number
54 lastHour: number
55 /** /clawd nap: bis zu diesem Tick gilt Nacht mit kurzem Leerlauf (Uhr und Einstellungen greifen erst danach wieder). */
56 napUntil: number
57 /** Zuletzt begrüßte Rückkehr (facts.backAt). */
58 seenBack: number
59}
60type State = { engine: Engine; ctl: Ctl; n: number }
61
62const NAP_TICKS = 4000 // ~5 Minuten
63
64const perfNow = (): number => (typeof performance !== 'undefined' ? performance.now() : 0)
65
66/** Aktuelle Zeit (ms): Basis aus dem Hooks-Modul plus Verstrichenes. Unter `ui.advance` zählt die Tickzahl, sonst die echte Uhr. */
67function clockNow(c: Ctl): number {
68 const byTicks = (c.ticks - c.baseTick) * TICK
69 const byPerf = perfNow() - c.basePerf
70 return c.baseNow + Math.max(byTicks, byPerf)
71}
72
73function hourOf(ms: number): number {
74 const d = new Date(ms)
75 return d.getHours() + d.getMinutes() / 60
76}
77
78function syncProps(c: Ctl, p: BuddyProps) {
79 if (p.now !== c.props.now) {
80 c.baseNow = p.now
81 c.baseTick = c.ticks
82 c.basePerf = perfNow()
83 }
84 c.props = p
85}
86
87function applyActions(st: State) {
88 const c = st.ctl
89 const e = st.engine
90 const p = c.props
91 if (p.demoN !== c.seenDemo) {
92 c.seenDemo = p.demoN
93 if (p.demo) e.play(p.demo)
94 }
95 if (p.napN !== c.seenNap) {
96 c.seenNap = p.napN
97 e.set({ hour: 23.5, nightStart: 22, nightEnd: 7, idleSeconds: 1 })
98 c.napUntil = c.ticks + NAP_TICKS
99 }
100 if (p.boopN !== c.seenBoop) {
101 c.seenBoop = p.boopN
102 e.click()
103 }
104 const back = p.facts.backAt ?? 0
105 if (back !== c.seenBack) {
106 c.seenBack = back
107 if (back && clockNow(c) - back < WELCOME_WINDOW_MS) e.welcome()
108 }
109 if (c.ticks >= c.napUntil) e.set({ nightStart: p.nightStart, nightEnd: p.nightEnd, idleSeconds: p.idleSeconds })
110 e.set({ reduced: p.reduced })
111}
112
113function tick(surface: ClientSurface<State>) {
114 const st = surface.state
115 if (!st) return
116 const c = st.ctl
117 c.ticks++
118 const now = clockNow(c)
119 if (c.ticks === c.napUntil) st.engine.set({ nightStart: c.props.nightStart, nightEnd: c.props.nightEnd, idleSeconds: c.props.idleSeconds })
120 if (c.ticks >= c.napUntil && (c.ticks % 50 === 1 || c.lastHour < 0 || c.ticks === c.napUntil)) {
121 c.lastHour = hourOf(now)
122 st.engine.set({ hour: c.lastHour, special: specialDay(now, c.props.birthday ?? '') })
123 }
124 if (c.ticks % 10 === 1 && c.props.strain) {
125 const t = deriveTemper(c.props.strain, now)
126 st.engine.set({ temper: t.temper, tired: t.tired })
127 }
128 st.engine.set({ agents: activeAgents(c.props.facts, now) })
129 const m = deriveMood(c.props.facts, now)
130 if (m !== c.mood) {
131 c.mood = m
132 st.engine.setMood(m)
133 }
134 st.engine.tick()
135 surface.setState({ engine: st.engine, ctl: c, n: st.n + 1 })
136}
137
138function pointer(surface: ClientSurface<State>, ev: ClientPointerEvent) {
139 const st = surface.state
140 if (!st) return
141 const p = st.ctl.props
142 const cols = Math.min(W, p.columns)
143 const rows = Math.min(H / 2, p.maxRows)
144 const x0 = W - cols
145 const r0 = H / 2 - rows
146 const sx = p.flip ? W - 1 - ev.x : x0 + ev.x
147 const sy = (ev.fine ? ev.fine.y : ev.y + 0.5) * 2 + r0 * 2
148 st.engine.render() // aktuelle Trefferkarte
149 st.engine.pointer(ev.type, sx, sy)
150 surface.setState({ engine: st.engine, ctl: st.ctl, n: st.n + 1 })
151 if (ev.type === 'up' && st.engine.S.annoy !== st.ctl.lastAnnoy) {
152 st.ctl.lastAnnoy = st.engine.S.annoy
153 surface.post({ k: 'stat', annoy: st.engine.S.annoy })
154 }
155}
156
157/** Pixelpuffer → Halbblock-Zeilen (Text mit eingebetteten Läufen gleicher Farben). */
158function drawRows(surface: ClientSurface<State>, buf: readonly (string | null)[], cols: number, rows: number, flip: boolean) {
159 const { Text } = surface.elements
160 const x0 = W - cols
161 const r0 = H / 2 - rows
162 const lines = []
163 for (let r = r0; r < r0 + rows; r++) {
164 const runs: { fg?: string; bg?: string; text: string }[] = []
165 for (let c = 0; c < cols; c++) {
166 const x = flip ? W - 1 - c : x0 + c
167 const top = buf[2 * r * W + x]
168 const bot = buf[(2 * r + 1) * W + x]
169 let ch = ' '
170 let fg: string | undefined
171 let bg: string | undefined
172 if (top && bot) {
173 if (top === bot) {
174 ch = '█'
175 fg = PAL[top]
176 } else {
177 ch = '▀'
178 fg = PAL[top]
179 bg = PAL[bot]
180 }
181 } else if (top) {
182 ch = '▀'
183 fg = PAL[top]
184 } else if (bot) {
185 ch = '▄'
186 fg = PAL[bot]
187 }
188 const prev = runs[runs.length - 1]
189 if (prev && prev.fg === fg && prev.bg === bg) prev.text += ch
190 else runs.push({ fg, bg, text: ch })
191 }
192 lines.push(
193 Text({
194 children: runs.map((run) => {
195 const tp: { color?: string; backgroundColor?: string } = {}
196 if (run.fg) tp.color = run.fg
197 if (run.bg) tp.backgroundColor = run.bg
198 return Text({ ...tp, children: [run.text] })
199 }),
200 }),
201 )
202 }
203 return lines
204}
205
206export default function Buddy(props: BuddyProps, surface: ClientSurface<State>) {
207 const { Box, Text } = surface.elements
208 // Einmal starten, solange der Zustand fehlt (types:1409). Das setState hier läuft genau einmal.
209 if (surface.state === undefined) {
210 const engine = createEngine({
211 clips: ALL_CLIPS, props: ALL_PROPS, rng: mulberry32(props.seed), hour: hourOf(props.now),
212 nightStart: props.nightStart, nightEnd: props.nightEnd, idleSeconds: props.idleSeconds, reduced: props.reduced,
213 })
214 engine.S.annoy = props.annoy
215 const ctl: Ctl = {
216 props, ticks: 0, baseNow: props.now, baseTick: 0, basePerf: perfNow(), mood: 'idle',
217 seenDemo: props.demoN, seenNap: props.napN, seenBoop: props.boopN, lastAnnoy: props.annoy, lastHour: -1, napUntil: -1, seenBack: 0,
218 }
219 engine.start()
220 const st: State = { engine, ctl, n: 0 }
221 surface.setState(st)
222 surface.every(TICK, () => tick(surface))
223 surface.onPointer((ev) => pointer(surface, ev))
224 return Text({ children: [' '] })
225 }
226 const st = surface.state
227 syncProps(st.ctl, props)
228 applyActions(st)
229 const cols = Math.min(W, props.columns)
230 const rows = Math.min(H / 2, props.maxRows)
231 if (cols < 20 || rows < 4) return Text({ children: [' '] }) // zu wenig Platz: nichts zeichnen
232 const { buf } = st.engine.render()
233 return Box({ flexDirection: 'column', width: cols, height: rows, children: drawRows(surface, buf, cols, rows, props.flip) })
234}
235hooks/props.ts 54 lines1// clawd-buddy: Basis-Requisiten (reine Daten). Weitere Requisiten liefern die Clip-Gruppen unter hooks/clips/.
2// Palettenzeichen siehe stage.ts → PAL. '.' ist durchsichtig. `effect`: kleiner Effekt, der aus der Figur entsteht
3// (Funkeln, Note, Herz, Schweiß …); alles andere muss im Bild herein- und hinausgeführt werden (siehe macros.ts).
4import type { PropSprite } from './stage.ts'
5
6const s = (rows: readonly string[]): PropSprite => ({ rows })
7const fx = (rows: readonly string[]): PropSprite => ({ rows, effect: true })
8
9export const BASE_PROPS: Readonly<Record<string, PropSprite>> = {
10 laptop: s(['DDDDDDD', 'DGGGGGD', 'DGGWGGD', 'DDDDDDD']),
11 magnifier: s(['.WWW..', 'W...W.', 'W...W.', '.WWW..', '....R.', '.....R']),
12 paper: s(['WWWWW', 'WGGGW', 'WWWWW', 'WGGWW', 'WWWWW']),
13 paper2: s(['WWWWW', 'WGGWW', 'WWWWW', 'WGGGW', 'WWWWW']),
14 book: s(['WWWKWWW', 'WGWKWGW', 'WWWKWWW', '.DDDDD.']),
15 bookFlip: s(['WWWKW..', 'WGWKWWW', 'WWWKWGW', '.DDDDD.']),
16 console: s(['DDDDD', 'DYDBD', 'DDDDD', 'DGDGD', 'DDDDD']),
17 console2: s(['DDDDD', 'DBDYD', 'DDDDD', 'DGDED', 'DDDDD']),
18 screen: s(['DDDDDD', 'DEEDDD', 'DDEEED', 'DEDDDD', 'DDDDDD']),
19 screen2: s(['DDDDDD', 'DDEEED', 'DEDDDD', 'DEEEDD', 'DDDDDD']),
20 globe: s(['.BBB.', 'BEBEB', 'BBBBB', 'BEBEB', '.BBB.']),
21 globe2: s(['.BBB.', 'EBEBB', 'BBBBB', 'EBEBB', '.BBB.']),
22 cup: s(['WWWW.', 'WRRWW', 'WRRW.', 'WWWW.']),
23 helper: s(['.OOO.', 'OKOKO', '.OOO.', '.O.O.']),
24 helperHi: s(['OOOO.', 'OKOKO', '.OOO.', '.O.O.']),
25 antenna: s(['.Y.', '.K.', '.K.', '.K.']),
26 waveL: fx(['.W', 'W.', '.W']),
27 waveR: fx(['W.', '.W', 'W.']),
28 note: fx(['.YY', '.Y.', 'YY.', 'YY.']),
29 sparkle: fx(['.Y.', 'YYY', '.Y.']),
30 dotY: fx(['Y']),
31 dotW: fx(['W']),
32 ballY: s(['Y']),
33 ballB: s(['B']),
34 ballP: s(['P']),
35 fly: s(['WK']),
36 pebble: s(['GG']),
37 question: fx(['WWW', '..W', '.WW', '...', '.W.']),
38 excl: fx(['W', 'W', 'W', '.', 'W']),
39 sweat: fx(['.L', 'LL', 'LL']),
40 heart: fx(['.P.P.', 'PPPPP', '.PPP.', '..P..']),
41 zs: fx(['WWW', '.W.', 'WWW']),
42 zb: fx(['WWWW', '..W.', '.W..', 'WWWW']),
43 cap: s(['.....NW', '...NNN.', '.NNNNN.']),
44 puff: fx(['.WW.', 'WWWW']),
45 steam1: fx(['.W', 'W.']),
46 steam2: fx(['W.', '.W']),
47 blockB: s(['BB']),
48 blockY: s(['YY']),
49 blockP: s(['PP']),
50 blockE: s(['EE']),
51 blockW: s(['WW']),
52 sun: s(['Y.Y', '.Y.', 'Y.Y']),
53}
54hooks/clipdef.ts 150 lines1// clawd-buddy: Clip-Modell (reine Daten und Funktionen).
2//
3// Ein Clip besteht aus bis zu drei Teilen, die nacheinander mit "klebenden" Feldern (Sticky-Merge) abgespielt werden:
4// intro – einmal, z. B. Gegenstand hervorholen
5// frames – der Hauptteil; bei `loop: true` wiederholt sich nur dieser Teil
6// outro – einmal beim Verlassen, z. B. Gegenstand wegräumen
7// Felder, die ein Frame nicht nennt, bleiben vom Vorgänger; `props` wird nur ersetzt, wenn der Frame es nennt.
8// `from` und `to` sind kanonische Posen (stage.ts → POSES), über die Clips verbunden werden.
9import { NUM, POSES, STRUCT } from './stage.ts'
10import type { Pose, PropRef } from './stage.ts'
11
12export type Frame = Partial<Omit<Pose, 'props'>> & { t?: number; props?: readonly PropRef[] }
13
14export type ClipDef = {
15 name: string
16 label: string
17 cat: string
18 from: string
19 to: string
20 loop: boolean
21 breathe: boolean
22 lookable: boolean
23 interruptible: boolean
24 /** Nur bei Nacht bzw. nur am Tag in die Auswahl nehmen (Standard 'any'). */
25 when: 'any' | 'night' | 'day'
26 /** Gewicht bei der Zufallsauswahl innerhalb der Gruppe (Standard 1). */
27 weight: number
28 /**
29 * Laune, zu der der Clip gehört (Engine gewichtet danach, siehe mood.ts → deriveTemper): 'bad' nur bei Frust (je stärker, desto
30 * häufiger), 'good' nur bei guter Laune, 'tired' nur bei Müdigkeit. Ohne Angabe: immer möglich.
31 */
32 temper?: 'bad' | 'good' | 'tired'
33 /**
34 * Der Clip zeichnet den Begleiter auf Platz 0 selbst (Interaktion mit einem laufenden Subagenten). Er beginnt und endet mit
35 * `agent_s` auf [MATE_X[0], MATE_Y]; die Begleiter-Ebene der Engine lässt Platz 0 so lange aus.
36 */
37 companion?: boolean
38 /**
39 * Gewichtsfaktoren nach Tageszeit: [Morgen 5–11 Uhr, Mittag 11–17, Abend 17–22, Nacht (Nachtfenster)]. Ohne Angabe überall 1.
40 * Nachts sind nur Clips mit Faktor > 0 als Zeitvertreib wählbar (Fynn: Zeitvertreib verändert sich über den Tag).
41 */
42 daypart?: readonly [number, number, number, number]
43 /** Nur an besonderen Tagen wählbar und dann bevorzugt: Geburtstag (userConfig `birthday`) bzw. Silvester/Neujahr. */
44 special?: 'birthday' | 'newyear'
45 /** Nur wählbar, wenn mindestens so viele Begleiter (Subagenten-Helfer) stehen, z. B. Gespräch mit dem zweiten Helfer. */
46 minMates?: number
47 /** Ausnahme: Augenformen aus einzelnen Pixeln (happy, angry, sad, tight, dizzy) bleiben erhalten (sonst rechteckig, SQUARE_EYES). */
48 shapedEyes?: boolean
49 /** false: nie gespiegelt abspielen (z. B. Clips, die bewusst nach links weiterlaufen). Standard: zufällig gespiegelt, wenn es passt. */
50 mirror?: boolean
51 intro: readonly Frame[]
52 frames: readonly Frame[]
53 outro: readonly Frame[]
54}
55
56export type Resolved = { p: Pose; t: number }
57export type ResolvedClip = { intro: Resolved[]; body: Resolved[]; outro: Resolved[] }
58
59/** Clip mit Voreinstellungen anlegen. `to` ist standardmäßig `from`. */
60export function clip(
61 name: string,
62 label: string,
63 cat: string,
64 def: Partial<Omit<ClipDef, 'name' | 'label' | 'cat'>> & { frames: readonly Frame[] },
65): ClipDef {
66 return {
67 name, label, cat, from: 'stand', loop: false, breathe: false, lookable: false, interruptible: false,
68 when: 'any', weight: 1, intro: [], outro: [], ...def, to: def.to ?? def.from ?? 'stand',
69 }
70}
71
72/** Sticky-Merge ab einer Startpose über eine Frameliste. */
73export function mergeFrames(start: Pose, frames: readonly Frame[]): Resolved[] {
74 let cur = start
75 return frames.map((f) => {
76 const { t, props, ...rest } = f
77 cur = { ...cur, ...rest, props: props !== undefined ? props : cur.props } as Pose
78 return { p: cur, t: Math.max(1, t || 1) }
79 })
80}
81
82/**
83 * Augen bleiben rechteckig (Fynn): Formen aus einzelnen Pixeln (^ ^, schräg, Spirale) wirken unruhig und sind nur in wenigen Clips
84 * erlaubt (`shapedEyes: true`). Sonst werden sie auf die nächste rechteckige Form abgebildet.
85 */
86export const SQUARE_EYES: Readonly<Record<string, string>> = { happy: 'closed', angry: 'half', sad: 'half', tight: 'closed', dizzy: 'wide' }
87const squareEyes = (l: Resolved[]): Resolved[] =>
88 l.map((f) => (SQUARE_EYES[f.p.eyes] ? { p: { ...f.p, eyes: SQUARE_EYES[f.p.eyes] }, t: f.t } : f))
89
90/** Clip auflösen: Intro, Hauptteil und Outro laufen auf derselben Sticky-Kette. */
91export function resolveClip(c: ClipDef): ResolvedClip {
92 const start = POSES[c.from]
93 const intro = mergeFrames(start, c.intro)
94 const afterIntro = intro.length ? intro[intro.length - 1].p : start
95 const body = mergeFrames(afterIntro, c.frames)
96 const afterBody = body.length ? body[body.length - 1].p : afterIntro
97 const outro = mergeFrames(afterBody, c.outro)
98 if (c.shapedEyes) return { intro, body, outro }
99 return { intro: squareEyes(intro), body: squareEyes(body), outro: squareEyes(outro) }
100}
101
102/** Ein Frame ist "sicher", wenn die Figur dort strukturell in ihrer Ausgangspose steht und der Clip unterbrechbar ist. */
103export function isSafe(c: ClipDef, p: Pose): boolean {
104 if (!c.interruptible) return false
105 const base = POSES[c.from]
106 return STRUCT.every((k) => p[k] === base[k]) && (p.legs === 'stand' || p.legs === base.legs)
107}
108
109/** Glättung: Sprünge > 1 px (fx, fy, by, squash) bekommen automatisch Zwischenbilder, auch beim Einstieg. */
110export function smooth(frames: readonly Resolved[], prev: Pose | null): Resolved[] {
111 const out: Resolved[] = []
112 let last = prev
113 for (const f of frames) {
114 if (last) {
115 const gap = Math.max(...NUM.map((k) => Math.abs(f.p[k] - last![k])))
116 for (let i = 1; i < gap; i++) {
117 const mid: Pose = { ...f.p }
118 for (const k of NUM) mid[k] = Math.round(last[k] + ((f.p[k] - last[k]) * i) / gap)
119 out.push({ p: mid, t: 1 })
120 }
121 }
122 out.push(f)
123 last = f.p
124 }
125 return out
126}
127
128/** Planer: kürzester Weg über Übergangsclips zwischen kanonischen Posen (Breitensuche). */
129export function planPath(clips: readonly ClipDef[], fromPose: string, toPose: string): string[] {
130 if (fromPose === toPose) return []
131 const edges = clips.filter((c) => c.cat === 'transition')
132 const seen = new Set([fromPose])
133 const queue: [string, string[]][] = [[fromPose, []]]
134 while (queue.length) {
135 const [pose, acc] = queue.shift()!
136 for (const e of edges) {
137 if (e.from === pose && !seen.has(e.to)) {
138 const next = [...acc, e.name]
139 if (e.to === toPose) return next
140 seen.add(e.to)
141 queue.push([e.to, next])
142 }
143 }
144 }
145 return []
146}
147
148/** Frames n-mal wiederholen. */
149export const rep = <T>(n: number, frames: readonly T[]): T[] => Array.from({ length: n }, () => frames).flat()
150