shadok-ai 0.8.26 → 0.8.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,7 +6,22 @@ import fs from "node:fs";
6
6
  import os from "node:os";
7
7
  import path from "node:path";
8
8
  import { fileURLToPath } from "node:url";
9
- import WebSocket from "ws";
9
+ // The WebSocket implementation, chosen at load: Node's BUILT-IN global first,
10
+ // the `ws` package only as a fallback.
11
+ //
12
+ // Why the built-in first: seeded into ~/.claude/skills so any agent can pilot,
13
+ // this file has no node_modules to resolve `ws` from, and the bare import broke
14
+ // every agent piloting outside the repo with ERR_MODULE_NOT_FOUND.
15
+ //
16
+ // Why `ws` is still needed: the global WebSocket is only unflagged from Node 22.
17
+ // This project supports Node >= 20 (README, and ci.yml runs the suite on 20),
18
+ // where `globalThis.WebSocket` is undefined — dropping the package outright
19
+ // traded a failure that hit agents outside the repo for one that hits EVERY
20
+ // Node 20 user, in-repo included. The `.catch` keeps the absent-package case
21
+ // (seeded copy) from throwing at import time, so the pure helpers this module
22
+ // exports stay usable; `makeWS` reports it instead, when someone actually
23
+ // connects.
24
+ const WS = globalThis.WebSocket ?? (await import("ws").catch(() => null))?.default;
10
25
 
11
26
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
12
27
  // Only used to auto-start a server when none is reachable (a dev running pilotctl
@@ -24,6 +39,31 @@ export const wsUrl = () => `ws://localhost:${port()}/ws`;
24
39
  export const authHeaders = () => (process.env.SHADOK_AUTH ? { cookie: process.env.SHADOK_AUTH } : {});
25
40
  export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
26
41
 
42
+ // The built-in WebSocket is WHATWG (addEventListener + event.data); the rest of
43
+ // this file uses the `ws` package's EventEmitter surface. Bridge them: `.on` /
44
+ // `.once`, handing a `message` listener the raw payload (event.data — a string
45
+ // for the JSON frames the server sends). Only these two methods are used here.
46
+ // `ws` already has them, so it is returned untouched.
47
+ //
48
+ // Both accept the non-standard `{ headers }` option, which is what carries the
49
+ // SHADOK_AUTH cookie past the password gate — verified against a real upgrade
50
+ // handler on the built-in, not assumed.
51
+ function makeWS(url, opts) {
52
+ if (!WS)
53
+ throw new Error(
54
+ "no WebSocket available: Node < 22 has no global one, and the `ws` package " +
55
+ "is not resolvable from here (a globally-seeded skill has no node_modules). " +
56
+ "Run pilotctl from the repo, or upgrade to Node 22+.",
57
+ );
58
+ const ws = new WS(url, opts);
59
+ if (typeof ws.on === "function") return ws; // the `ws` package: already an emitter
60
+ const add = (type, fn, once) =>
61
+ ws.addEventListener(type, type === "message" ? (e) => fn(e.data) : fn, once ? { once: true } : undefined);
62
+ ws.on = (type, fn) => (add(type, fn, false), ws);
63
+ ws.once = (type, fn) => (add(type, fn, true), ws);
64
+ return ws;
65
+ }
66
+
27
67
  export function stateDir() {
28
68
  return process.env.SHADOK_STATE_DIR ?? path.join(os.homedir(), ".shadok-ai", "pilotctl");
29
69
  }
@@ -69,7 +109,7 @@ export function parseArgs(argv) {
69
109
 
70
110
  function connect() {
71
111
  return new Promise((resolve, reject) => {
72
- const ws = new WebSocket(wsUrl(), { headers: authHeaders() });
112
+ const ws = makeWS(wsUrl(), { headers: authHeaders() });
73
113
  ws.once("open", () => resolve(ws));
74
114
  ws.once("error", reject);
75
115
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shadok-ai",
3
- "version": "0.8.26",
3
+ "version": "0.8.28",
4
4
  "main": "dist/session.js",
5
5
  "scripts": {
6
6
  "test": "node --import tsx --test test/*.ts context/*/test/*.test.mjs",
package/public/index.html CHANGED
@@ -11,7 +11,7 @@
11
11
  <title>shadok-ai — cockpit</title>
12
12
  <!-- The idle favicon: a resting Shadok. paint() swaps in the pumping / "!" / "?"
13
13
  variants once the JS runs; this is only the pre-script icon. -->
14
- <link rel="icon" id="favicon" href="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2032%2032%22%3E%3Crect%20width%3D%2232%22%20height%3D%2232%22%20rx%3D%227%22%20fill%3D%22%2314161d%22%2F%3E%3Cg%20fill%3D%22%23f0a848%22%3E%3Crect%20x%3D%2212%22%20y%3D%2224%22%20width%3D%221.9%22%20height%3D%227%22%20rx%3D%220.95%22%2F%3E%3Crect%20x%3D%2216.1%22%20y%3D%2224%22%20width%3D%221.9%22%20height%3D%227%22%20rx%3D%220.95%22%2F%3E%3Crect%20x%3D%229.8%22%20y%3D%2229.8%22%20width%3D%224.8%22%20height%3D%221.9%22%20rx%3D%220.95%22%2F%3E%3Crect%20x%3D%2215.4%22%20y%3D%2229.8%22%20width%3D%224.8%22%20height%3D%221.9%22%20rx%3D%220.95%22%2F%3E%3C%2Fg%3E%3Cg%3E%3Cellipse%20cx%3D%227.7%22%20cy%3D%2219.5%22%20rx%3D%222.1%22%20ry%3D%223.5%22%20fill%3D%22%23f0a848%22%20transform%3D%22rotate(-16%207.7%2019.5)%22%2F%3E%3Cellipse%20cx%3D%2222.3%22%20cy%3D%2219.5%22%20rx%3D%222.1%22%20ry%3D%223.5%22%20fill%3D%22%23f0a848%22%20transform%3D%22rotate(16%2022.3%2019.5)%22%2F%3E%3Cellipse%20cx%3D%2215%22%20cy%3D%2219.5%22%20rx%3D%227.8%22%20ry%3D%228.2%22%20fill%3D%22%23f0a848%22%2F%3E%3Crect%20x%3D%2213.4%22%20y%3D%228%22%20width%3D%223.2%22%20height%3D%225%22%20rx%3D%221.6%22%20fill%3D%22%23f0a848%22%2F%3E%3Ccircle%20cx%3D%2215%22%20cy%3D%226.2%22%20r%3D%224.3%22%20fill%3D%22%23f0a848%22%2F%3E%3Cpath%20d%3D%22M13.6%209.8%20L16.4%209.8%20L15%2011.8%20Z%22%20fill%3D%22%23f0a848%22%2F%3E%3Ccircle%20cx%3D%2213.1%22%20cy%3D%226%22%20r%3D%222%22%20fill%3D%22%23fff%22%2F%3E%3Ccircle%20cx%3D%2216.9%22%20cy%3D%226%22%20r%3D%222%22%20fill%3D%22%23fff%22%2F%3E%3Ccircle%20cx%3D%2213.1%22%20cy%3D%226.1%22%20r%3D%221.1%22%20fill%3D%22%2314161d%22%2F%3E%3Ccircle%20cx%3D%2216.9%22%20cy%3D%226.1%22%20r%3D%221.1%22%20fill%3D%22%2314161d%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E">
14
+ <link rel="icon" id="favicon" href="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2032%2032%22%3E%3Crect%20width%3D%2232%22%20height%3D%2232%22%20rx%3D%227%22%20fill%3D%22%2314161d%22%2F%3E%3Cline%20x1%3D%2210.8%22%20y1%3D%2223%22%20x2%3D%2210.4%22%20y2%3D%2230.6%22%20stroke%3D%22%23f0a848%22%20stroke-width%3D%221.3%22%20stroke-linecap%3D%22round%22%2F%3E%3Cline%20x1%3D%2216.400000000000002%22%20y1%3D%2223%22%20x2%3D%2216.8%22%20y2%3D%2230.6%22%20stroke%3D%22%23f0a848%22%20stroke-width%3D%221.3%22%20stroke-linecap%3D%22round%22%2F%3E%3Cline%20x1%3D%228.8%22%20y1%3D%2230.7%22%20x2%3D%2211.9%22%20y2%3D%2230.7%22%20stroke%3D%22%23f0a848%22%20stroke-width%3D%221.3%22%20stroke-linecap%3D%22round%22%2F%3E%3Cline%20x1%3D%2215.4%22%20y1%3D%2230.7%22%20x2%3D%2218.5%22%20y2%3D%2230.7%22%20stroke%3D%22%23f0a848%22%20stroke-width%3D%221.3%22%20stroke-linecap%3D%22round%22%2F%3E%3Cline%20x1%3D%2219.6%22%20y1%3D%2217.2%22%20x2%3D%2224.000000000000004%22%20y2%3D%2220.4%22%20stroke%3D%22%23f0a848%22%20stroke-width%3D%221.3%22%20stroke-linecap%3D%22round%22%2F%3E%3Cg%3E%3Cline%20x1%3D%2211.600000000000001%22%20y1%3D%225.399999999999999%22%20x2%3D%229.600000000000001%22%20y2%3D%222.8%22%20stroke%3D%22%23f0a848%22%20stroke-width%3D%220.9%22%20stroke-linecap%3D%22round%22%2F%3E%3Ccircle%20cx%3D%229.600000000000001%22%20cy%3D%222.8%22%20r%3D%221%22%20fill%3D%22%23f0a848%22%2F%3E%3Cline%20x1%3D%2216%22%20y1%3D%225.399999999999999%22%20x2%3D%2218%22%20y2%3D%222.8%22%20stroke%3D%22%23f0a848%22%20stroke-width%3D%220.9%22%20stroke-linecap%3D%22round%22%2F%3E%3Ccircle%20cx%3D%2218%22%20cy%3D%222.8%22%20r%3D%221%22%20fill%3D%22%23f0a848%22%2F%3E%3Ccircle%20cx%3D%2213.8%22%20cy%3D%2216.2%22%20r%3D%228.4%22%20fill%3D%22%23f0a848%22%2F%3E%3Cpath%20d%3D%22M12.200000000000001%2018.2%20L15.4%2018.2%20L13.8%2021.6%20Z%22%20fill%3D%22%23f0a848%22%2F%3E%3Ccircle%20cx%3D%2210.9%22%20cy%3D%227.799999999999999%22%20r%3D%222.6%22%20fill%3D%22%23fff%22%2F%3E%3Ccircle%20cx%3D%2216.7%22%20cy%3D%227.799999999999999%22%20r%3D%222.6%22%20fill%3D%22%23fff%22%2F%3E%3Ccircle%20cx%3D%2211.200000000000001%22%20cy%3D%228.1%22%20r%3D%221.3%22%20fill%3D%22%2314161d%22%2F%3E%3Ccircle%20cx%3D%2217%22%20cy%3D%228.1%22%20r%3D%221.3%22%20fill%3D%22%2314161d%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E">
15
15
  <style>
16
16
  :root {
17
17
  --bg: #14161d;
@@ -1017,17 +1017,12 @@
1017
1017
  }
1018
1018
  @keyframes tw-shimmer { from { background-position: 200% 0; } to { background-position: -200% 0; } }
1019
1019
  /* The pumping Shadok: `color` feeds its `currentColor` fill so it follows the
1020
- palette. The body bobs and the arm swings — the pump — on the same beat. */
1020
+ palette. It is animated by re-drawing in JS (renderTw), not CSS — the pump's
1021
+ arm has to be re-linked to the lever each frame. */
1021
1022
  #thinking .tw-shadok { display: inline-flex; color: var(--amber); }
1022
1023
  #thinking .tw-shadok svg { width: 22px; height: 22px; overflow: visible; }
1023
- /* The whole Shadok (body + arm + handle) bobs with translate — no
1024
- transform-origin, which is a cross-browser trap on SVG — while the pump rod
1025
- and legs stay planted, so the handle strokes up and down the rod. */
1026
- #thinking .tw-shadok .sh-bob { animation: tw-bob 0.55s ease-in-out infinite; }
1027
- @keyframes tw-bob { 0%, 100% { transform: translateY(-1.8px); } 50% { transform: translateY(2.2px); } }
1028
1024
  @media (prefers-reduced-motion: reduce) {
1029
1025
  #thinking .tw-text { animation: none; color: var(--phosphor); -webkit-text-fill-color: currentColor; }
1030
- #thinking .tw-shadok .sh-bob { animation: none; }
1031
1026
  }
1032
1027
 
1033
1028
  /* Composer */
@@ -3241,50 +3236,53 @@
3241
3236
  const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
3242
3237
  return v || fallback;
3243
3238
  }
3244
- /** The Shadok itself — the cockpit's mascot, which famously PUMPS. Drawn
3245
- * FRONT-ON: a plump round body, a short neck, a round head with two big googly
3246
- * eyes (their signature — a side profile with a long beak read as a dinosaur),
3247
- * a tiny beak, two stub wings, two long legs. One drawing serves every use.
3248
- * `bob` poses one frame of the pump (the favicon animates by re-generating);
3249
- * `anim` instead tags the bobbing group so CSS drives it (the DOM indicator
3250
- * only — CSS animation inside a favicon data URI is unreliable). `pump` adds a
3251
- * FIXED rod on the right the Shadok works with its wing; that steady reference,
3252
- * with the planted legs, is what makes the bob read at favicon size. `sym`
3253
- * stamps a bold "!" / "?" over the belly, outlined in the ground colour so the
3254
- * accent-on-accent "?" still reads (the eyes stay clear above it). Legs (and,
3255
- * pumping, the rod) stay planted; the body/head/wings bob. The Shadok shifts a
3256
- * little left when pumping, to seat the rod on its right. Eyes are always
3257
- * white + dark, never the accent, so they read on any `fill`. */
3258
- function shadokInner(fill, { bob = 0, pump = false, anim = false, sym = null, symColor } = {}) {
3239
+ /** The Shadok itself — the cockpit's mascot, which famously PUMPS. Drawn like
3240
+ * the real thing (Rouxel): a round BALL body, two big googly eyes perched on
3241
+ * top, two antennae, a tiny beak, thin stick legs. One drawing serves every
3242
+ * state. `lever` (degrees around the fulcrum) poses the see-saw hand-pump it
3243
+ * works — pass a number to show the pump, null for the idle stub arm; `bob`
3244
+ * dips the ball with the effort. The arm is recomputed each call from the
3245
+ * (bobbed) shoulder to the lever end it grips, so the linkage is always right —
3246
+ * animating the arm with CSS instead made it swing around the pivot, backwards.
3247
+ * `sym` stamps a bold "!" / "?" over the ball, outlined in the ground colour so
3248
+ * the accent-on-accent "?" still reads (eyes stay clear above it). Legs and the
3249
+ * fulcrum stay planted. Eyes are always white + dark, never the accent, so they
3250
+ * read on any `fill` (the `currentColor` indicator included). */
3251
+ const PX = 23.2, PY = 24.4; // the pump lever's fulcrum
3252
+ function shadokInner(fill, { lever = null, bob = 0, sym = null, symColor } = {}) {
3259
3253
  const base = themeColor("--bg", "#14161d");
3260
- const hx = pump ? 13.6 : 15; // body/head centre X
3261
- const hy = 6.2; // head centre Y
3262
- const bobCls = anim ? ' class="sh-bob"' : "";
3263
- const bobTf = anim ? "" : ` transform="translate(0 ${bob})"`;
3264
- // The right wing rests low, or — pumping — angles up to work the rod's handle.
3265
- const rWing = pump
3266
- ? `<ellipse cx="${hx + 7.6}" cy="17.8" rx="2.1" ry="3.4" fill="${fill}" transform="rotate(28 ${hx + 7.6} 17.8)"/>`
3267
- : `<ellipse cx="${hx + 7.3}" cy="19.5" rx="2.1" ry="3.5" fill="${fill}" transform="rotate(16 ${hx + 7.3} 19.5)"/>`;
3268
- const rod = pump ? `<rect x="23.4" y="15.5" width="1.5" height="13.7" rx="0.6" fill="${fill}"/>` : "";
3254
+ const cx = 13.8, cy = 16.2, r = 8.4, eyeY = cy - 8.4, eyeSep = 5.8, eyeR = 2.6, pupR = 1.3;
3255
+ const shX = cx + r - 2.6, shY = cy + 1.8 + bob; // shoulder (moves with the bob)
3256
+ let fulcrum = "", leverEl = "", armEl = "";
3257
+ if (lever !== null) {
3258
+ const rad = (lever * Math.PI) / 180, nearX = PX - 6.5, farX = PX + 5;
3259
+ const ax = PX + (nearX - PX) * Math.cos(rad), ay = PY + (nearX - PX) * Math.sin(rad);
3260
+ const bx = PX + (farX - PX) * Math.cos(rad), by = PY + (farX - PX) * Math.sin(rad);
3261
+ fulcrum = `<path d="M${PX - 2.2} 29 L${PX + 2.2} 29 L${PX} ${PY} Z" fill="${fill}"/>`;
3262
+ leverEl = `<line x1="${ax}" y1="${ay}" x2="${bx}" y2="${by}" stroke="${fill}" stroke-width="1.7" stroke-linecap="round"/>`;
3263
+ armEl = `<line x1="${shX}" y1="${shY}" x2="${ax}" y2="${ay}" stroke="${fill}" stroke-width="1.3" stroke-linecap="round"/>`;
3264
+ } else {
3265
+ armEl = `<line x1="${shX}" y1="${cy + 1 + bob}" x2="${cx + r + 1.8}" y2="${cy + 4.2 + bob}" stroke="${fill}" stroke-width="1.3" stroke-linecap="round"/>`;
3266
+ }
3269
3267
  const glyph = sym
3270
- ? `<text x="${hx}" y="22" font-family="monospace" font-size="15.5" font-weight="800" fill="${symColor || fill}" stroke="${base}" stroke-width="2.6" paint-order="stroke" text-anchor="middle">${sym}</text>`
3268
+ ? `<text x="${cx}" y="${cy + 3.4 + bob}" font-family="ui-monospace, monospace" font-size="14" font-weight="800" fill="${symColor || fill}" stroke="${base}" stroke-width="2.4" paint-order="stroke" text-anchor="middle">${sym}</text>`
3271
3269
  : "";
3272
3270
  return (
3273
- rod +
3274
- // legs + feet (planted, not part of the bob)
3275
- `<g fill="${fill}">` +
3276
- `<rect x="${hx - 3}" y="24" width="1.9" height="7" rx="0.95"/><rect x="${hx + 1.1}" y="24" width="1.9" height="7" rx="0.95"/>` +
3277
- `<rect x="${hx - 5.2}" y="29.8" width="4.8" height="1.9" rx="0.95"/><rect x="${hx + 0.4}" y="29.8" width="4.8" height="1.9" rx="0.95"/>` +
3278
- `</g>` +
3279
- // wings + body + neck + head + beak + two eyes — all bob together
3280
- `<g${bobCls}${bobTf}>` +
3281
- `<ellipse cx="${hx - 7.3}" cy="19.5" rx="2.1" ry="3.5" fill="${fill}" transform="rotate(-16 ${hx - 7.3} 19.5)"/>${rWing}` +
3282
- `<ellipse cx="${hx}" cy="19.5" rx="7.8" ry="8.2" fill="${fill}"/>` +
3283
- `<rect x="${hx - 1.6}" y="8" width="3.2" height="5" rx="1.6" fill="${fill}"/>` +
3284
- `<circle cx="${hx}" cy="${hy}" r="4.3" fill="${fill}"/>` +
3285
- `<path d="M${hx - 1.4} ${hy + 3.6} L${hx + 1.4} ${hy + 3.6} L${hx} ${hy + 5.6} Z" fill="${fill}"/>` +
3286
- `<circle cx="${hx - 1.9}" cy="${hy - 0.2}" r="2" fill="#fff"/><circle cx="${hx + 1.9}" cy="${hy - 0.2}" r="2" fill="#fff"/>` +
3287
- `<circle cx="${hx - 1.9}" cy="${hy - 0.1}" r="1.1" fill="${base}"/><circle cx="${hx + 1.9}" cy="${hy - 0.1}" r="1.1" fill="${base}"/>` +
3271
+ fulcrum + leverEl +
3272
+ // thin legs + feet (planted)
3273
+ `<line x1="${cx - 3}" y1="${cy + r - 1.6}" x2="${cx - 3.4}" y2="30.6" stroke="${fill}" stroke-width="1.3" stroke-linecap="round"/>` +
3274
+ `<line x1="${cx + 2.6}" y1="${cy + r - 1.6}" x2="${cx + 3}" y2="30.6" stroke="${fill}" stroke-width="1.3" stroke-linecap="round"/>` +
3275
+ `<line x1="${cx - 5}" y1="30.7" x2="${cx - 1.9}" y2="30.7" stroke="${fill}" stroke-width="1.3" stroke-linecap="round"/>` +
3276
+ `<line x1="${cx + 1.6}" y1="30.7" x2="${cx + 4.7}" y2="30.7" stroke="${fill}" stroke-width="1.3" stroke-linecap="round"/>` +
3277
+ armEl +
3278
+ // ball body + antennae + beak + eyes — bob together with the effort
3279
+ `<g transform="translate(0 ${bob})">` +
3280
+ `<line x1="${cx - 2.2}" y1="${eyeY - 2.4}" x2="${cx - 4.2}" y2="2.8" stroke="${fill}" stroke-width="0.9" stroke-linecap="round"/><circle cx="${cx - 4.2}" cy="2.8" r="1" fill="${fill}"/>` +
3281
+ `<line x1="${cx + 2.2}" y1="${eyeY - 2.4}" x2="${cx + 4.2}" y2="2.8" stroke="${fill}" stroke-width="0.9" stroke-linecap="round"/><circle cx="${cx + 4.2}" cy="2.8" r="1" fill="${fill}"/>` +
3282
+ `<circle cx="${cx}" cy="${cy}" r="${r}" fill="${fill}"/>` +
3283
+ `<path d="M${cx - 1.6} ${cy + 2} L${cx + 1.6} ${cy + 2} L${cx} ${cy + 5.4} Z" fill="${fill}"/>` +
3284
+ `<circle cx="${cx - eyeSep / 2}" cy="${eyeY}" r="${eyeR}" fill="#fff"/><circle cx="${cx + eyeSep / 2}" cy="${eyeY}" r="${eyeR}" fill="#fff"/>` +
3285
+ `<circle cx="${cx - eyeSep / 2 + 0.3}" cy="${eyeY + 0.3}" r="${pupR}" fill="${base}"/><circle cx="${cx + eyeSep / 2 + 0.3}" cy="${eyeY + 0.3}" r="${pupR}" fill="${base}"/>` +
3288
3286
  `</g>` +
3289
3287
  glyph
3290
3288
  );
@@ -3306,14 +3304,16 @@
3306
3304
  const accent = themeColor("--amber", "#f0a848");
3307
3305
  return faviconWrap(shadokInner(accent, symbol ? { sym: symbol, symColor } : {}));
3308
3306
  }
3309
- /** The "working" favicon: the Shadok pumping. `phase` (0..PUMP_FRAMES-1) rides a
3310
- * cycle; the whole Shadok heaves up and down against the fixed rod. */
3311
- const PUMP_FRAMES = 8;
3307
+ /** The pump pose for a phase p (0..1..0): the Shadok pushes the lever DOWN (near
3308
+ * end down = negative angle) and the ball dips with the effort — shared by the
3309
+ * favicon and the in-page indicator so both pump the same way. */
3310
+ function pumpPose(p) { return { lever: 13 - p * 28, bob: +(p * 1.5).toFixed(2) }; }
3311
+ /** The "working" favicon: the Shadok pumping its lever. `phase` rides a cycle. */
3312
+ const PUMP_FRAMES = 12;
3312
3313
  function faviconPumpingSVG(phase) {
3313
3314
  const accent = themeColor("--amber", "#f0a848");
3314
3315
  const p = (Math.sin((phase / PUMP_FRAMES) * 2 * Math.PI - Math.PI / 2) + 1) / 2; // 0..1..0
3315
- const bob = +(-2 + p * 4.4).toFixed(2); // -2 (up) .. +2.4 (down): a big, visible stroke
3316
- return faviconWrap(shadokInner(accent, { bob, pump: true }));
3316
+ return faviconWrap(shadokInner(accent, pumpPose(p)));
3317
3317
  }
3318
3318
  /** What each channel is asking for, as the pure module wants it. */
3319
3319
  function attentionInput() {
@@ -3377,15 +3377,27 @@
3377
3377
  }
3378
3378
  }
3379
3379
  function refreshBadge() { paint(); }
3380
- // The in-page "pumping" Shadok, drawn once from the SAME `shadokInner` as the
3381
- // favicon. `currentColor` lets the CSS palette (var(--amber)) tint it, so it
3382
- // follows a theme change with no redraw; `anim` tags the body/arm for the CSS
3383
- // pump. The element sits earlier in the document, so it's here to fill now.
3384
- {
3385
- const twShadok = document.querySelector("#thinking .tw-shadok");
3386
- if (twShadok)
3387
- twShadok.innerHTML =
3388
- `<svg viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">${shadokInner("currentColor", { pump: true, anim: true })}</svg>`;
3380
+ // The in-page "Shadok is pumping" indicator uses the SAME `shadokInner`, tinted
3381
+ // via `currentColor` so it follows the palette. It animates by RE-DRAWING each
3382
+ // frame (like the favicon), not CSS: the pump's arm must be re-linked to the
3383
+ // lever every frame. A single heartbeat re-draws only while the indicator is
3384
+ // visible (it shows only during a turn), so an idle cockpit pays almost nothing.
3385
+ const twShadok = document.querySelector("#thinking .tw-shadok");
3386
+ const thinkingEl = document.getElementById("thinking");
3387
+ function renderTw(p) {
3388
+ if (!twShadok) return;
3389
+ const { lever, bob } = pumpPose(p);
3390
+ twShadok.innerHTML =
3391
+ `<svg viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">${shadokInner("currentColor", { lever, bob })}</svg>`;
3392
+ }
3393
+ renderTw(0); // populate immediately so it isn't blank on first show
3394
+ if (!(window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches)) {
3395
+ let twPhase = 0;
3396
+ setInterval(() => {
3397
+ if (!thinkingEl || !thinkingEl.classList.contains("visible")) return;
3398
+ twPhase = (twPhase + 1) % PUMP_FRAMES;
3399
+ renderTw((Math.sin((twPhase / PUMP_FRAMES) * 2 * Math.PI - Math.PI / 2) + 1) / 2);
3400
+ }, 95);
3389
3401
  }
3390
3402
  // Coming back must stop the blink at once, not at the next tick. Both signals
3391
3403
  // matter: switching tabs fires visibilitychange, switching application only