@ramarivera/coding-buddy 0.9.0 → 0.9.2

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.
@@ -1,15 +1,27 @@
1
1
  #!/usr/bin/env bash
2
2
  # Invoked from VHS tapes. Args: <cols> <fixture-relpath-from-repo-root>
3
+ # BUDDY_STATUSLINE_ROWS defaults to 50 (full density) so golden renders are not
4
+ # sensitive to the VHS terminal's actual row count.
3
5
  set -euo pipefail
4
6
  ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
5
7
  cols="${1:?cols}"
6
8
  fixture_rel="${2:?fixture}"
7
9
  payload='{"session_id":"ci","model":{"display_name":"CI"},"workspace":{"current_dir":"."}}'
10
+
11
+ if [[ "$fixture_rel" = /* ]]; then
12
+ cfg_dir="$fixture_rel"
13
+ else
14
+ cfg_dir="$ROOT/$fixture_rel"
15
+ fi
16
+
17
+ rows="${BUDDY_STATUSLINE_ROWS:-50}"
18
+
8
19
  cd "$ROOT"
9
20
  printf '%s' "$payload" \
10
21
  | COLUMNS="$cols" \
11
22
  BUDDY_FAKE_NOW=0 \
12
- CLAUDE_CONFIG_DIR="$ROOT/$fixture_rel" \
23
+ BUDDY_STATUSLINE_ROWS="$rows" \
24
+ CLAUDE_CONFIG_DIR="$cfg_dir" \
13
25
  CLAUDE_CODE_SESSION_ID= \
14
26
  TMUX_PANE= \
15
27
  BUDDY_SHELL= \
@@ -0,0 +1,86 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Derives a status.json fixture with full/compact/minimal frame arrays.
4
+ *
5
+ * Reads a base status object from stdin, computes frames from species/eye/hat,
6
+ * and writes the augmented object to the file named by the first argument.
7
+ *
8
+ * Usage:
9
+ * cat base-status.json | bun scripts/ci/write-density-status.ts dest.json
10
+ * bun scripts/ci/write-density-status.ts dest.json < base-status.json
11
+ */
12
+ import { writeFileSync } from "fs";
13
+ import { getStatusFrames } from "../../server/art.ts";
14
+ import { renderFace, resolveEyeGlyph, type Species } from "../../core/engine.ts";
15
+
16
+ function deriveCompactFrames(fullFrames: string[]): string[] {
17
+ return fullFrames.map((body) => {
18
+ const lines = body.split("\n");
19
+ const selected: string[] = [];
20
+ for (const line of lines) {
21
+ if (selected.length >= 3) break;
22
+ if (line.trim() !== "") selected.push(line);
23
+ }
24
+ while (selected.length < 3) selected.push("");
25
+ return selected.join("\n");
26
+ });
27
+ }
28
+
29
+ function deriveMinimalFrames(species: Species, eye: string): string[] {
30
+ const face = renderFace(species, resolveEyeGlyph(eye));
31
+ return Array.from({ length: 4 }, () => face);
32
+ }
33
+
34
+ async function main() {
35
+ const dest = process.argv[2];
36
+ if (!dest) {
37
+ console.error("usage: write-density-status.ts <dest.json>");
38
+ process.exit(2);
39
+ }
40
+
41
+ let input = "";
42
+ for await (const chunk of process.stdin) {
43
+ input += chunk;
44
+ }
45
+ const base = input ? JSON.parse(input) : {};
46
+
47
+ const bones = {
48
+ species: base.species ?? "pikachu",
49
+ rarity: base.rarity ?? "common",
50
+ eye: base.eye ?? "o",
51
+ hat: base.hat ?? "none",
52
+ shiny: base.shiny ?? false,
53
+ stats: base.stats ?? { DEBUGGING: 50, PATIENCE: 50, CHAOS: 50, WISDOM: 50, SNARK: 50 },
54
+ peak: base.peak ?? "DEBUGGING",
55
+ dump: base.dump ?? "PATIENCE",
56
+ };
57
+
58
+ const result = getStatusFrames(bones) as {
59
+ frames: string[];
60
+ frameSequence: number[];
61
+ compactFrames?: string[];
62
+ minimalFrames?: string[];
63
+ };
64
+
65
+ const compactFrames =
66
+ result.compactFrames && result.compactFrames.length > 0
67
+ ? result.compactFrames
68
+ : deriveCompactFrames(result.frames);
69
+
70
+ const minimalFrames =
71
+ result.minimalFrames && result.minimalFrames.length > 0
72
+ ? result.minimalFrames
73
+ : deriveMinimalFrames(bones.species as Species, bones.eye);
74
+
75
+ const out = {
76
+ ...base,
77
+ frames: result.frames,
78
+ compactFrames,
79
+ minimalFrames,
80
+ frameSequence: result.frameSequence,
81
+ };
82
+
83
+ writeFileSync(dest, JSON.stringify(out));
84
+ }
85
+
86
+ main();
package/server/art.ts CHANGED
@@ -6,11 +6,12 @@
6
6
  * {E} is replaced with the eye character at render time.
7
7
  */
8
8
 
9
- import { resolveEyeGlyph, type Species, type Hat, type Rarity, type StatName, type BuddyBones } from "../core/engine.ts"
9
+ import { renderFace, resolveEyeGlyph, type Species, type Hat, type Rarity, type StatName, type BuddyBones } from "../core/engine.ts"
10
10
  export { resolveEyeGlyph };
11
11
  import { HAT_ART } from "../core/art-data.ts";
12
- import { getRarityColor } from "./theme.ts";
12
+
13
13
  export { HAT_ART };
14
+ import { getRarityColor } from "./theme.ts";
14
15
 
15
16
  // ─── Species art: 3 frames × 5 lines each ──────────────────────────────────
16
17
 
@@ -265,6 +266,8 @@ export const STATUS_FRAME_SEQUENCE: readonly number[] = [
265
266
  // \n-joined 5-line string (one jq call + mapfile in bash).
266
267
  export function getStatusFrames(bones: BuddyBones): {
267
268
  frames: string[];
269
+ compactFrames: string[];
270
+ minimalFrames: string[];
268
271
  frameSequence: number[];
269
272
  } {
270
273
  const resolveFrame = (frameIdx: number, eyeGlyph: string): string => {
@@ -277,16 +280,34 @@ export function getStatusFrames(bones: BuddyBones): {
277
280
  return art.join("\n");
278
281
  };
279
282
 
283
+ // Compact: keep the first three non-empty rows of each full frame. This
284
+ // preserves a hat if it occupies the top row, otherwise it drops the blank
285
+ // placeholder and shows the face/body.
286
+ const deriveCompactFrames = (fullFrames: string[]): string[] =>
287
+ fullFrames.map((body) => {
288
+ const lines = body.split("\n");
289
+ const selected: string[] = [];
290
+ for (const line of lines) {
291
+ if (selected.length >= 3) break;
292
+ if (line.trim() !== "") selected.push(line);
293
+ }
294
+ while (selected.length < 3) selected.push("");
295
+ return selected.join("\n");
296
+ });
297
+
280
298
  // Sanitize once at the boundary. Blink uses a literal "-" eye, which is
281
299
  // intentionally outside EYES and must not pass through resolveEyeGlyph.
282
300
  const eye = resolveEyeGlyph(bones.eye);
301
+ const frames = [
302
+ resolveFrame(0, eye),
303
+ resolveFrame(1, eye),
304
+ resolveFrame(2, eye),
305
+ resolveFrame(0, "-"),
306
+ ];
283
307
  return {
284
- frames: [
285
- resolveFrame(0, eye),
286
- resolveFrame(1, eye),
287
- resolveFrame(2, eye),
288
- resolveFrame(0, "-"),
289
- ],
308
+ frames,
309
+ compactFrames: deriveCompactFrames(frames),
310
+ minimalFrames: Array.from({ length: frames.length }, () => renderFace(bones.species, eye)),
290
311
  frameSequence: [...STATUS_FRAME_SEQUENCE],
291
312
  };
292
313
  }
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
- import { mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
3
+ import { mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from "fs";
4
4
  import { join } from "path";
5
5
  import { reactionPool } from "./reaction-data.ts";
6
6
  import {
@@ -44,7 +44,9 @@ const BUDDY_COMMENT_PATTERN = /<!--\s*buddy:\s*([\s\S]*?)\s*-->/g;
44
44
  * Provenance of the reaction the hook wrote. Mirrors server/state.ts
45
45
  * `ReactionSource`; loaded as `none` on legacy files without the field.
46
46
  */
47
- export type ReactionSource = "comment" | "fallback" | "none";
47
+ // "tool" is returned when a buddy_react reaction was adopted rather than
48
+ // written by this hook; it mirrors server/state.ts's ReactionSource.
49
+ export type ReactionSource = "tool" | "comment" | "fallback" | "none";
48
50
 
49
51
  export interface BuddyCommentResult {
50
52
  comment?: string;
@@ -131,19 +133,83 @@ function readTimestampSeconds(path: string): number {
131
133
  * is clamped to `now` so a skewed clock on a prior run does not poison
132
134
  * the comparison.
133
135
  */
134
- function toolFiredThisTurn(
135
- reactionPath: string,
136
+ /**
137
+ * How long after a stop marker a tool reaction still counts as "this turn".
138
+ *
139
+ * The Stop hook can legitimately fire more than once per turn — duplicate
140
+ * registrations accumulate in settings.json, and one dogfooding machine was
141
+ * observed with EIGHT Stop entries. Without this grace window the first
142
+ * invocation adopts the buddy_react reaction and stamps the marker, and every
143
+ * subsequent invocation then judges that same reaction stale and overwrites
144
+ * the bubble with a canned pool line. The user only ever sees the last write.
145
+ */
146
+ const SAME_TURN_GRACE_MS = 10_000;
147
+
148
+ /**
149
+ * Hard ceiling on how old an adopted tool reaction may be.
150
+ *
151
+ * A brand-new session has no stop marker, so `readTimestampSeconds` returns 0
152
+ * and every positive timestamp would otherwise look "fresh" — letting a
153
+ * leftover reaction from an unrelated session surface on the first turn.
154
+ * Bounded to the statusline's default reactionTTL: never adopt something the
155
+ * statusline would already treat as expired.
156
+ */
157
+ const MAX_ADOPTION_AGE_MS = 900_000;
158
+
159
+ function isFreshToolReaction(
160
+ candidate: ReactionFile | null,
136
161
  stopMarkerPath: string,
137
162
  nowMs: number,
138
163
  ): boolean {
139
- const existing = readJsonFile<ReactionFile>(reactionPath);
140
- if (!existing || existing.source !== "tool") return false;
141
- const ts = typeof existing.timestamp === "number" ? existing.timestamp : 0;
164
+ if (!candidate || candidate.source !== "tool") return false;
165
+ const ts = typeof candidate.timestamp === "number" ? candidate.timestamp : 0;
142
166
  if (ts <= 0) return false;
143
167
  if (ts > nowMs + FUTURE_TIMESTAMP_TOLERANCE_MS) return false;
168
+ if (nowMs - ts > MAX_ADOPTION_AGE_MS) return false;
144
169
  const lastStopSec = readTimestampSeconds(stopMarkerPath);
145
170
  const effectiveLastStopMs = Math.min(lastStopSec * 1000, nowMs);
146
- return ts > effectiveLastStopMs;
171
+ return ts > effectiveLastStopMs - SAME_TURN_GRACE_MS;
172
+ }
173
+
174
+ /**
175
+ * The MCP server and this hook do not always agree on the session id: the
176
+ * server is long-lived and resolves BUDDY_SID once at launch, while the hook
177
+ * and the statusline resolve it per invocation. When they diverge,
178
+ * `buddy_react` writes a real model-authored reaction into a file nothing
179
+ * renders, this hook sees an empty file for *its* session, and overwrites the
180
+ * bubble with a canned pool line — which looks exactly like the reactions
181
+ * being fake.
182
+ *
183
+ * So look for a fresh tool reaction across every session file, not just ours,
184
+ * and adopt it. Ours still wins when both are fresh.
185
+ */
186
+ function findFreshToolReaction(
187
+ stateDir: string,
188
+ ownReactionPath: string,
189
+ stopMarkerPath: string,
190
+ nowMs: number,
191
+ ): ReactionFile | null {
192
+ const own = readJsonFile<ReactionFile>(ownReactionPath);
193
+ if (isFreshToolReaction(own, stopMarkerPath, nowMs)) return own;
194
+
195
+ let best: ReactionFile | null = null;
196
+ let entries: string[] = [];
197
+ try {
198
+ entries = readdirSync(stateDir);
199
+ } catch {
200
+ return null;
201
+ }
202
+ for (const entry of entries) {
203
+ if (!entry.startsWith("reaction.") || !entry.endsWith(".json")) continue;
204
+ const path = join(stateDir, entry);
205
+ if (path === ownReactionPath) continue;
206
+ const candidate = readJsonFile<ReactionFile>(path);
207
+ if (!isFreshToolReaction(candidate, stopMarkerPath, nowMs)) continue;
208
+ const bestTs = typeof best?.timestamp === "number" ? best.timestamp : 0;
209
+ const candidateTs = typeof candidate?.timestamp === "number" ? candidate.timestamp : 0;
210
+ if (!best || candidateTs > bestTs) best = candidate;
211
+ }
212
+ return best;
147
213
  }
148
214
 
149
215
  export function handleBuddyComment(
@@ -170,9 +236,22 @@ export function handleBuddyComment(
170
236
  const cooldown = nonNegativeInteger(config.commentCooldown, 30);
171
237
 
172
238
  // ─── Don't clobber a buddy_react tool reaction from this turn ───────────
173
- if (toolFiredThisTurn(reactionPath, stopMarkerFile, now)) {
239
+ // Checked across all session files: see findFreshToolReaction for why.
240
+ const freshTool = findFreshToolReaction(stateDir, reactionPath, stopMarkerFile, now);
241
+ if (freshTool) {
242
+ // Adopt it into this session's file so the statusline — which reads only
243
+ // its own session — actually renders the model-authored line.
244
+ const own = readJsonFile<ReactionFile>(reactionPath);
245
+ let wrote = false;
246
+ if (own?.reaction !== freshTool.reaction || own?.source !== "tool") {
247
+ mkdirSync(stateDir, { recursive: true });
248
+ atomicWriteJson(reactionPath, freshTool);
249
+ wrote = true;
250
+ }
174
251
  atomicWriteTimestamp(stopMarkerFile, now);
175
- return { source: "none", updated: false };
252
+ // `updated` reports whether this invocation wrote the bubble, so adoption
253
+ // counts — a consumer using it to trigger a re-render must see the write.
254
+ return { source: "tool", updated: wrote };
176
255
  }
177
256
 
178
257
  // ─── Cooldown: rate-limit the reaction write AND bookkeeping ──────────
package/server/state.ts CHANGED
@@ -30,10 +30,12 @@ import {
30
30
  rmSync,
31
31
  } from "fs";
32
32
  import { join } from "path";
33
- import type { Companion } from "../core/engine.ts"
33
+ import { configuredSharedUserId } from "../core/identity.ts";
34
+ import type { BuddyBones, Companion } from "../core/engine.ts"
34
35
  import type * as CoreEngine from "../core/engine.ts";
35
36
  import type * as Art from "./art.ts";
36
- import { configuredSharedUserId } from "../core/identity.ts";
37
+ import type * as MoodModule from "./mood.ts";
38
+ import type * as XpModule from "./xp.ts";
37
39
  import {
38
40
  buddyStateDir,
39
41
  claudeSettingsPath,
@@ -41,9 +43,17 @@ import {
41
43
  toUnixPath,
42
44
  } from "./path.ts";
43
45
 
44
- export const STATE_DIR = buddyStateDir();
45
- const MANIFEST_FILE = join(STATE_DIR, "menagerie.json");
46
- const CONFIG_FILE = join(STATE_DIR, "config.json");
46
+ // Resolved per call, never captured at import. ES modules are cached, so a
47
+ // module-level `buddyStateDir()` pinned these paths to whatever
48
+ // CLAUDE_CONFIG_DIR happened to be at *first* import. Once any file in a test
49
+ // process had imported this module, every later redirect was silently ignored —
50
+ // which is how `bun test` came to overwrite a user's live companion with a test
51
+ // fixture. Individually the test files were clean; only running them together
52
+ // leaked. A lazy lookup makes that class of bug impossible.
53
+ const stateDir = () => buddyStateDir();
54
+ const MANIFEST_FILE = () => join(stateDir(), "menagerie.json");
55
+ const CONFIG_FILE = () => join(stateDir(), "config.json");
56
+ const STATUS_FILE = () => join(stateDir(), "status.json");
47
57
 
48
58
  // ─── Session ID (Claude Code session isolation, tmux pane fallback) ─────────
49
59
  //
@@ -65,7 +75,7 @@ function sessionId(): string {
65
75
  }
66
76
 
67
77
  function reactionFile(): string {
68
- return join(STATE_DIR, `reaction.${sessionId()}.json`);
78
+ return join(stateDir(), `reaction.${sessionId()}.json`);
69
79
  }
70
80
 
71
81
  // ─── Manifest schema ─────────────────────────────────────────────────────────
@@ -83,7 +93,7 @@ function emptyManifest(): Manifest {
83
93
 
84
94
  function loadManifest(): Manifest {
85
95
  try {
86
- const raw = readFileSync(MANIFEST_FILE, "utf8");
96
+ const raw = readFileSync(MANIFEST_FILE(), "utf8");
87
97
  const m = JSON.parse(raw) as Manifest;
88
98
  if (!m.companions) m.companions = {};
89
99
  return m;
@@ -93,10 +103,10 @@ function loadManifest(): Manifest {
93
103
  }
94
104
 
95
105
  function saveManifest(m: Manifest): void {
96
- mkdirSync(STATE_DIR, { recursive: true });
97
- const tmp = MANIFEST_FILE + ".tmp";
106
+ mkdirSync(stateDir(), { recursive: true });
107
+ const tmp = MANIFEST_FILE() + ".tmp";
98
108
  writeFileSync(tmp, JSON.stringify(m, null, 2));
99
- renameSync(tmp, MANIFEST_FILE); // atomic on same filesystem
109
+ renameSync(tmp, MANIFEST_FILE()); // atomic on same filesystem
100
110
  }
101
111
 
102
112
  // ─── Slot helpers ────────────────────────────────────────────────────────────
@@ -222,13 +232,13 @@ export function saveCompanion(companion: Companion): void {
222
232
  // ─── Migration: legacy companion.json -> single manifest ────────────────────
223
233
 
224
234
  function migrateIfNeeded(): void {
225
- if (existsSync(MANIFEST_FILE)) return;
235
+ if (existsSync(MANIFEST_FILE())) return;
226
236
 
227
237
  const companions: Record<string, Companion> = {};
228
238
  let active = "buddy";
229
239
 
230
240
  // Absorb menagerie/<slot>.json files
231
- const menagerieDir = join(STATE_DIR, "menagerie");
241
+ const menagerieDir = join(stateDir(), "menagerie");
232
242
  if (existsSync(menagerieDir)) {
233
243
  try {
234
244
  for (const f of readdirSync(menagerieDir).filter((f) =>
@@ -249,7 +259,7 @@ function migrateIfNeeded(): void {
249
259
  }
250
260
 
251
261
  // Absorb legacy companion.json
252
- const legacyFile = join(STATE_DIR, "companion.json");
262
+ const legacyFile = join(stateDir(), "companion.json");
253
263
  if (existsSync(legacyFile) && Object.keys(companions).length === 0) {
254
264
  try {
255
265
  const c: Companion = JSON.parse(readFileSync(legacyFile, "utf8"));
@@ -262,7 +272,7 @@ function migrateIfNeeded(): void {
262
272
  }
263
273
 
264
274
  // Read active pointer if it exists
265
- const activeFile = join(STATE_DIR, "active");
275
+ const activeFile = join(stateDir(), "active");
266
276
  if (existsSync(activeFile)) {
267
277
  try {
268
278
  const a = readFileSync(activeFile, "utf8").trim();
@@ -322,7 +332,7 @@ export function saveReaction(
322
332
  reason: string,
323
333
  source: ReactionSource = "fallback",
324
334
  ): void {
325
- mkdirSync(STATE_DIR, { recursive: true });
335
+ mkdirSync(stateDir(), { recursive: true });
326
336
  const state: ReactionState = { reaction, timestamp: Date.now(), reason, source };
327
337
  // Atomic via tmp + rename — torn reads on the reaction file would
328
338
  // make the Stop hook's freshness check see an absent file, pinning
@@ -353,6 +363,7 @@ export interface BuddyConfig {
353
363
  bubblePosition: "top" | "left";
354
364
  showRarity: boolean;
355
365
  statusLineEnabled: boolean;
366
+ statuslineDensity: "auto" | "full" | "compact" | "minimal";
356
367
  statuslineWidthAdjust: number;
357
368
  bubbleWidth: number;
358
369
  bubbleMargin: number;
@@ -366,7 +377,6 @@ export interface BuddyConfig {
366
377
  suggestionsEnabled: boolean;
367
378
  suggestionCooldown: number;
368
379
  }
369
-
370
380
  const DEFAULT_CONFIG: BuddyConfig = {
371
381
  commentCooldown: 30,
372
382
  // Long enough that a reaction survives the turn you were reading when it was
@@ -377,6 +387,7 @@ const DEFAULT_CONFIG: BuddyConfig = {
377
387
  bubblePosition: "top",
378
388
  showRarity: true,
379
389
  statusLineEnabled: false,
390
+ statuslineDensity: "auto",
380
391
  statuslineWidthAdjust: 0,
381
392
  bubbleWidth: 28,
382
393
  bubbleMargin: 8,
@@ -406,22 +417,26 @@ export function normalizeConfig(data: unknown): BuddyConfig {
406
417
  if (!Number.isInteger(config.statuslineWidthAdjust)) {
407
418
  config.statuslineWidthAdjust = DEFAULT_CONFIG.statuslineWidthAdjust;
408
419
  }
420
+ const allowedDensities: BuddyConfig["statuslineDensity"][] = ["auto", "full", "compact", "minimal"];
421
+ if (!allowedDensities.includes(config.statuslineDensity)) {
422
+ config.statuslineDensity = DEFAULT_CONFIG.statuslineDensity;
423
+ }
409
424
  return config;
410
425
  }
411
426
 
412
427
  export function loadConfig(): BuddyConfig {
413
428
  try {
414
- return normalizeConfig(JSON.parse(readFileSync(CONFIG_FILE, "utf8")));
429
+ return normalizeConfig(JSON.parse(readFileSync(CONFIG_FILE(), "utf8")));
415
430
  } catch {
416
431
  return normalizeConfig({});
417
432
  }
418
433
  }
419
434
 
420
435
  export function saveConfig(config: Partial<BuddyConfig>): BuddyConfig {
421
- mkdirSync(STATE_DIR, { recursive: true });
436
+ mkdirSync(stateDir(), { recursive: true });
422
437
  const current = loadConfig();
423
438
  const merged = { ...current, ...config };
424
- writeFileSync(CONFIG_FILE, JSON.stringify(merged, null, 2));
439
+ writeFileSync(CONFIG_FILE(), JSON.stringify(merged, null, 2));
425
440
  return merged;
426
441
  }
427
442
 
@@ -440,12 +455,36 @@ export interface StatusState {
440
455
  muted: boolean;
441
456
  achievement: string;
442
457
  frames: string[];
458
+ compactFrames: string[];
459
+ minimalFrames: string[];
443
460
  frameSequence: number[];
444
461
  level: number;
445
462
  xp: number;
446
463
  mood: string;
447
464
  }
448
465
 
466
+ // Density frame fallbacks used when server/art.ts has not yet been updated
467
+ // to emit compactFrames/minimalFrames. The art worker owns the pre-rendered
468
+ // density frames; state.ts only derives a degraded fallback from the full
469
+ // frames so old/new status.json remains renderable.
470
+ function deriveCompactFrames(fullFrames: string[]): string[] {
471
+ return fullFrames.map((body) => {
472
+ const lines = body.split("\n");
473
+ const selected: string[] = [];
474
+ for (const line of lines) {
475
+ if (selected.length >= 3) break;
476
+ if (line.trim() !== "") selected.push(line);
477
+ }
478
+ while (selected.length < 3) selected.push("");
479
+ return selected.join("\n");
480
+ });
481
+ }
482
+
483
+ function deriveMinimalFrames(bones: BuddyBones, eye: string): string[] {
484
+ const { renderFace } = require("../core/engine.ts") as typeof CoreEngine;
485
+ const face = renderFace(bones.species, eye);
486
+ return Array.from({ length: 4 }, () => face);
487
+ }
449
488
  export function writeStatusState(
450
489
  companion: Companion,
451
490
  reaction?: string,
@@ -454,18 +493,32 @@ export function writeStatusState(
454
493
  level?: number,
455
494
  xp?: number,
456
495
  ): void {
457
- mkdirSync(STATE_DIR, { recursive: true });
496
+ mkdirSync(stateDir(), { recursive: true });
458
497
  const { renderFace, RARITY_STARS, resolveEyeGlyph } =
459
498
  require("../core/engine.ts") as typeof CoreEngine;
460
499
  const { getStatusFrames } =
461
500
  require("./art.ts") as typeof Art;
462
501
  const safeEye = resolveEyeGlyph(companion.bones.eye);
463
- const { frames, frameSequence } = getStatusFrames(companion.bones);
502
+ const artResult = getStatusFrames(companion.bones) as {
503
+ frames: string[];
504
+ frameSequence: number[];
505
+ compactFrames?: string[];
506
+ minimalFrames?: string[];
507
+ };
508
+ const { frames, frameSequence } = artResult;
509
+ const compactFrames =
510
+ artResult.compactFrames && artResult.compactFrames.length > 0
511
+ ? artResult.compactFrames
512
+ : deriveCompactFrames(frames);
513
+ const minimalFrames =
514
+ artResult.minimalFrames && artResult.minimalFrames.length > 0
515
+ ? artResult.minimalFrames
516
+ : deriveMinimalFrames(companion.bones, safeEye);
464
517
  let xpLevel = level ?? 1;
465
518
  let xpTotal = xp ?? 0;
466
519
  let moodStr = "focused";
467
520
  try {
468
- const { getXpState } = require("./xp.ts") as typeof import("./xp.ts");
521
+ const { getXpState } = require("./xp.ts") as typeof XpModule;
469
522
  const xpState = getXpState();
470
523
  xpLevel = xpState.level;
471
524
  xpTotal = xpState.totalXp;
@@ -473,7 +526,7 @@ export function writeStatusState(
473
526
  // XP state is optional during first install / version skew.
474
527
  }
475
528
  try {
476
- const { getMood } = require("./mood.ts") as typeof import("./mood.ts");
529
+ const { getMood } = require("./mood.ts") as typeof MoodModule;
477
530
  moodStr = getMood().current;
478
531
  } catch {
479
532
  // Mood state is optional during first install / version skew.
@@ -491,11 +544,18 @@ export function writeStatusState(
491
544
  muted: muted ?? false,
492
545
  achievement: achievement ?? "",
493
546
  frames,
547
+ compactFrames,
548
+ minimalFrames,
494
549
  frameSequence,
495
550
  level: xpLevel,
496
551
  xp: xpTotal,
497
552
  mood: moodStr,
498
553
  };
554
+ // Atomic: the statusline reads this file on a timer, so a plain write can
555
+ // be observed half-flushed and blank the companion for a tick.
556
+ const statusTmp = `${STATUS_FILE()}.tmp`;
557
+ writeFileSync(statusTmp, JSON.stringify(state));
558
+ renameSync(statusTmp, STATUS_FILE());
499
559
  // writeStatusState no longer calls saveReaction implicitly. Callers
500
560
  // that need a reaction file do so explicitly with the correct
501
561
  // `source` tag — the old implicit save had no source argument and
@@ -580,11 +640,11 @@ const TRANSIENT_PREFIXES = [
580
640
  *
581
641
  * Companion data (menagerie.json, status.json, config.json) is intentionally
582
642
  * kept — users reinstalling get their buddy back. Call-sites that want a full
583
- * wipe should delete STATE_DIR themselves after calling this.
643
+ * wipe should delete stateDir() themselves after calling this.
584
644
  */
585
645
  export function cleanupPluginState(
586
646
  settingsPath: string = CLAUDE_SETTINGS_PATH,
587
- stateDir: string = STATE_DIR,
647
+ stateDir: string = buddyStateDir(),
588
648
  ): CleanupResult {
589
649
  const statusLineRemoved = unsetBuddyStatusLine(settingsPath);
590
650