SLOPSHOPPER

clawd-buddy

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…

newbandrowsguardcommandprompt
★ 3v0.7.1MITupdated 2026-10-09FynnXland/fynn-mods/mods/clawd-buddy
A shopper browsing a rack in a slop shop
Preview could not run: ReferenceError: performance is not defined at newMeter (file:///inner.js:7629:21) at anonymous (file:///inner.js:7644:21) at run (inner.js:982:9) at /home/runner/work/slopshopper/slops
README

clawd-buddy

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 language to de for German.

Tested with Claude Code v2.1.295 (terminal and desktop app) · Plugin version 0.7.1

What it does

Clawd runs on his own. What he does depends on what is happening:

SituationWhat 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-fivemagnifying 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 staps 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 startsbriefly 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 handoffholds 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 abortedjumps 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 typingreads along, takes notes, rubs his hands
Idlepastimes 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 Evecake 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 daygets 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 secondsat 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
MoodPiling 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.
InputEffect
/clawd or /clawd statuson/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 offshow or hide him (persists)
/clawd listall animations by group
/clawd demo <name>play one animation, e.g. /clawd demo type_laptop
/clawd napsends him to sleep
/clawd booppokes 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 flickerdesktop 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): clickgiggles; many clicks in a row make him annoyed, then offended
Mouse (terminal): drag an armthe arm follows briefly and snaps back
Mouse (terminal): hold the body for ~0.7 s, then draghe 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.

Configuration

/config → clawd-buddy:

KeyTitle in /configMeaningDefault
nightStartNight starts (hour)Hour (0 to 23) from which he gets tired and falls asleep when idle23
nightEndNight ends (hour)Hour (0 to 23) until which it counts as night; if it is before the start, the night spans midnight6
idleSecondsIdle time before pastimes (seconds)Seconds without activity before he starts a pastime (10 to 600)45
reducedMotionReduced motionOnly the base pose and calm variants, no pastimesoff
sidePosition in the bandright or left above the promptright
birthdayBirthday (DD.MM.)Day (DD.MM.) on which he celebrates with you; empty = off. New Year's Eve is always on05.01.
languageLanguage / SpracheLanguage of the mod's texts: en English, de Germanen

Language

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.

Rights

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).
  • Hook 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.
  • Hook 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.
  • Hook session.compact: notices only that the main conversation is being compacted and when it ends. Never reads the conversation.
  • Hook 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.

Installation

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.

Known limitations

  • Desktop app: The client frame does not load there. The hooks module computes up to the next 30 seconds ahead and sends them as one SVG with SMIL animation, which the app draws as an image (without 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.
  • Mouse in the terminal only works where the terminal delivers mouse events (fullscreen). After a click on the figure it may keep the keyboard focus until you press Esc.
  • After a hidden desktop session is shown again, Clawd keeps playing without a visible restart (checked in the desktop app with 0.6.12). How the app treats the image while the session is hidden is not documented, so this may change with an app update.
  • Mood is per Claude Code process and is not saved. "Working on the same feature for a long time" is approximated by the time spent working in one stretch (the mod cannot see what the work is about).
  • Voice input: There is no mod event for "recording in progress", and 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.

Credits

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.

Source 29 files
hooks/register.ts 774 lines
1// 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}
774
hooks/band.ts 108 lines
1// 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
108
hooks/library.ts 41 lines
1// 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}
41
hooks/mood.ts 317 lines
1// 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}
317
hooks/desk.ts 351 lines
1// 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
351
hooks/i18n.ts 356 lines
1// 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))
356
hooks/clawdhelp.ts 77 lines
1// 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}
77
hooks/help.ts 205 lines
1// 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}
205
hooks/stage.ts 355 lines
1// 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}
355
hooks/buddy.ts 235 lines
1// 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}
235
hooks/props.ts 54 lines
1// 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}
54
hooks/clipdef.ts 150 lines
1// 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