@ramarivera/coding-buddy 0.8.0 → 0.9.1

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.
Files changed (56) hide show
  1. package/README.md +23 -4
  2. package/adapters/omp/context.ts +1 -1
  3. package/adapters/omp/events.ts +286 -81
  4. package/adapters/omp/index.ts +3 -1
  5. package/adapters/omp/renderers.ts +4 -54
  6. package/adapters/omp/storage.ts +6 -1
  7. package/adapters/omp/ui.ts +17 -6
  8. package/adapters/pi/events.ts +280 -83
  9. package/adapters/pi/index.ts +11 -2
  10. package/adapters/pi/renderers.ts +3 -47
  11. package/adapters/pi/storage.ts +6 -1
  12. package/adapters/pi/ui.ts +17 -6
  13. package/adapters/shared/file-storage.ts +22 -0
  14. package/adapters/shared/widget-layout.ts +248 -2
  15. package/cli/settings.ts +1 -1
  16. package/cli/tui.tsx +1 -1
  17. package/core/command-service.ts +13 -0
  18. package/core/engine.ts +11 -2
  19. package/core/identity.ts +15 -0
  20. package/core/model.ts +3 -0
  21. package/core/ports.ts +4 -0
  22. package/core/reactions.ts +29 -2
  23. package/core/render-model.ts +4 -3
  24. package/package.json +6 -3
  25. package/scripts/ci/Dockerfile.vhs +125 -0
  26. package/scripts/ci/check-statusline.sh +441 -14
  27. package/scripts/ci/check-vhs-goldens.sh +97 -0
  28. package/scripts/ci/fonts.conf +37 -0
  29. package/scripts/ci/goldens/baseline-40.png +0 -0
  30. package/scripts/ci/goldens/baseline-80.png +0 -0
  31. package/scripts/ci/goldens/cjk-80.png +0 -0
  32. package/scripts/ci/goldens/emoji-80.png +0 -0
  33. package/scripts/ci/render-statusline-pty.py +107 -0
  34. package/scripts/ci/render-vhs-goldens.sh +226 -0
  35. package/scripts/ci/statusline.tape +28 -2
  36. package/scripts/ci/vhs-density-fixtures.sh +27 -0
  37. package/scripts/ci/vhs-run-statusline.sh +28 -0
  38. package/scripts/ci/write-density-status.ts +86 -0
  39. package/scripts/gen-emoji-widths.ts +41 -17
  40. package/scripts/paths.sh +3 -1
  41. package/server/art.ts +70 -17
  42. package/server/hooks/buddy-comment.ts +166 -17
  43. package/server/hooks/file-type-react.ts +1 -1
  44. package/server/hooks/mood-react.ts +1 -1
  45. package/server/hooks/name-react.ts +1 -1
  46. package/server/hooks/react.ts +1 -1
  47. package/server/hooks/reaction-data.ts +10 -3
  48. package/server/index.ts +67 -27
  49. package/server/path.ts +3 -0
  50. package/server/state.ts +147 -41
  51. package/skills/buddy/SKILL.md +1 -1
  52. package/statusline/buddy-status.sh +488 -76
  53. package/statusline/emoji-text.data +3 -0
  54. package/statusline/emoji-widths.data +2 -3
  55. package/statusline/substatus.sh +52 -10
  56. package/scripts/postinstall.cjs +0 -42
package/server/state.ts CHANGED
@@ -30,7 +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"
35
+ import type * as CoreEngine from "../core/engine.ts";
36
+ import type * as Art from "./art.ts";
37
+ import type * as MoodModule from "./mood.ts";
38
+ import type * as XpModule from "./xp.ts";
34
39
  import {
35
40
  buddyStateDir,
36
41
  claudeSettingsPath,
@@ -38,9 +43,17 @@ import {
38
43
  toUnixPath,
39
44
  } from "./path.ts";
40
45
 
41
- export const STATE_DIR = buddyStateDir();
42
- const MANIFEST_FILE = join(STATE_DIR, "menagerie.json");
43
- 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");
44
57
 
45
58
  // ─── Session ID (Claude Code session isolation, tmux pane fallback) ─────────
46
59
  //
@@ -62,7 +75,7 @@ function sessionId(): string {
62
75
  }
63
76
 
64
77
  function reactionFile(): string {
65
- return join(STATE_DIR, `reaction.${sessionId()}.json`);
78
+ return join(stateDir(), `reaction.${sessionId()}.json`);
66
79
  }
67
80
 
68
81
  // ─── Manifest schema ─────────────────────────────────────────────────────────
@@ -80,7 +93,7 @@ function emptyManifest(): Manifest {
80
93
 
81
94
  function loadManifest(): Manifest {
82
95
  try {
83
- const raw = readFileSync(MANIFEST_FILE, "utf8");
96
+ const raw = readFileSync(MANIFEST_FILE(), "utf8");
84
97
  const m = JSON.parse(raw) as Manifest;
85
98
  if (!m.companions) m.companions = {};
86
99
  return m;
@@ -90,10 +103,10 @@ function loadManifest(): Manifest {
90
103
  }
91
104
 
92
105
  function saveManifest(m: Manifest): void {
93
- mkdirSync(STATE_DIR, { recursive: true });
94
- const tmp = MANIFEST_FILE + ".tmp";
106
+ mkdirSync(stateDir(), { recursive: true });
107
+ const tmp = MANIFEST_FILE() + ".tmp";
95
108
  writeFileSync(tmp, JSON.stringify(m, null, 2));
96
- renameSync(tmp, MANIFEST_FILE); // atomic on same filesystem
109
+ renameSync(tmp, MANIFEST_FILE()); // atomic on same filesystem
97
110
  }
98
111
 
99
112
  // ─── Slot helpers ────────────────────────────────────────────────────────────
@@ -219,13 +232,13 @@ export function saveCompanion(companion: Companion): void {
219
232
  // ─── Migration: legacy companion.json -> single manifest ────────────────────
220
233
 
221
234
  function migrateIfNeeded(): void {
222
- if (existsSync(MANIFEST_FILE)) return;
235
+ if (existsSync(MANIFEST_FILE())) return;
223
236
 
224
237
  const companions: Record<string, Companion> = {};
225
238
  let active = "buddy";
226
239
 
227
240
  // Absorb menagerie/<slot>.json files
228
- const menagerieDir = join(STATE_DIR, "menagerie");
241
+ const menagerieDir = join(stateDir(), "menagerie");
229
242
  if (existsSync(menagerieDir)) {
230
243
  try {
231
244
  for (const f of readdirSync(menagerieDir).filter((f) =>
@@ -246,7 +259,7 @@ function migrateIfNeeded(): void {
246
259
  }
247
260
 
248
261
  // Absorb legacy companion.json
249
- const legacyFile = join(STATE_DIR, "companion.json");
262
+ const legacyFile = join(stateDir(), "companion.json");
250
263
  if (existsSync(legacyFile) && Object.keys(companions).length === 0) {
251
264
  try {
252
265
  const c: Companion = JSON.parse(readFileSync(legacyFile, "utf8"));
@@ -259,7 +272,7 @@ function migrateIfNeeded(): void {
259
272
  }
260
273
 
261
274
  // Read active pointer if it exists
262
- const activeFile = join(STATE_DIR, "active");
275
+ const activeFile = join(stateDir(), "active");
263
276
  if (existsSync(activeFile)) {
264
277
  try {
265
278
  const a = readFileSync(activeFile, "utf8").trim();
@@ -278,32 +291,63 @@ function migrateIfNeeded(): void {
278
291
 
279
292
  // ─── Reaction state (session-scoped for tmux isolation) ──────────────────────
280
293
 
294
+ /**
295
+ * Where a reaction came from. Proves the bubble content isn't canned when
296
+ * source === "tool" or "comment"; "fallback" means a hook picked a pool line
297
+ * because no model-authored text surfaced; "none" is the default for legacy
298
+ * files written before the field existed.
299
+ *
300
+ * tool — buddy_react MCP tool call (Claude wrote it; renders nowhere)
301
+ * comment — old `<!-- buddy: ... -->` HTML comment (legacy / older CC)
302
+ * fallback — Stop hook generated it from the canned pool
303
+ * none — unknown / legacy file without a source field
304
+ */
305
+ export type ReactionSource = "tool" | "comment" | "fallback" | "none";
306
+
281
307
  export interface ReactionState {
282
308
  reaction: string;
283
309
  timestamp: number;
284
310
  reason: string;
311
+ /** Provenance — see ReactionSource. Defaults to "none" on legacy files. */
312
+ source?: ReactionSource;
285
313
  }
286
314
 
287
315
  export function loadReaction(): ReactionState | null {
288
316
  try {
289
317
  const data: ReactionState = JSON.parse(readFileSync(reactionFile(), "utf8"));
290
318
  const { reactionTTL } = loadConfig();
291
- if (reactionTTL > 0 && Date.now() - data.timestamp > reactionTTL * 1000) return null;
319
+ if (reactionTTL > 0 && Date.now() - data.timestamp > reactionTTL * 1000) {
320
+ rmSync(reactionFile(), { force: true });
321
+ return null;
322
+ }
323
+ if (data.source === undefined) data.source = "none";
292
324
  return data;
293
325
  } catch {
294
326
  return null;
295
327
  }
296
328
  }
297
329
 
298
- export function saveReaction(reaction: string, reason: string): void {
299
- mkdirSync(STATE_DIR, { recursive: true });
300
- const state: ReactionState = { reaction, timestamp: Date.now(), reason };
301
- writeFileSync(reactionFile(), JSON.stringify(state));
330
+ export function saveReaction(
331
+ reaction: string,
332
+ reason: string,
333
+ source: ReactionSource = "fallback",
334
+ ): void {
335
+ mkdirSync(stateDir(), { recursive: true });
336
+ const state: ReactionState = { reaction, timestamp: Date.now(), reason, source };
337
+ // Atomic via tmp + rename — torn reads on the reaction file would
338
+ // make the Stop hook's freshness check see an absent file, pinning
339
+ // a stale tool reaction into the bubble forever.
340
+ const target = reactionFile();
341
+ const tmp = `${target}.tmp.${process.pid}.${Date.now()}`;
342
+ writeFileSync(tmp, JSON.stringify(state));
343
+ renameSync(tmp, target);
302
344
  }
303
345
 
304
346
  // ─── Identity resolution ─────────────────────────────────────────────────────
305
347
 
306
348
  export function resolveUserId(): string {
349
+ const sharedUserId = configuredSharedUserId();
350
+ if (sharedUserId) return sharedUserId;
307
351
  try {
308
352
  const claudeJson = JSON.parse(readFileSync(claudeUserConfigPath(), "utf8"));
309
353
  return claudeJson.oauthAccount?.accountUuid ?? claudeJson.userID ?? "anon";
@@ -311,9 +355,7 @@ export function resolveUserId(): string {
311
355
  return "anon";
312
356
  }
313
357
  }
314
-
315
358
  // ─── Config persistence (PR #6: tmux popup settings) ─────────────────────────
316
-
317
359
  export interface BuddyConfig {
318
360
  commentCooldown: number;
319
361
  reactionTTL: number;
@@ -321,6 +363,8 @@ export interface BuddyConfig {
321
363
  bubblePosition: "top" | "left";
322
364
  showRarity: boolean;
323
365
  statusLineEnabled: boolean;
366
+ statuslineDensity: "auto" | "full" | "compact" | "minimal";
367
+ statuslineWidthAdjust: number;
324
368
  bubbleWidth: number;
325
369
  bubbleMargin: number;
326
370
  useCombinedStatus: boolean;
@@ -333,14 +377,18 @@ export interface BuddyConfig {
333
377
  suggestionsEnabled: boolean;
334
378
  suggestionCooldown: number;
335
379
  }
336
-
337
380
  const DEFAULT_CONFIG: BuddyConfig = {
338
381
  commentCooldown: 30,
339
- reactionTTL: 0,
382
+ // Long enough that a reaction survives the turn you were reading when it was
383
+ // written, short enough that yesterday's bubble is never still on screen.
384
+ // Set to 0 to opt out and keep reactions until they are replaced.
385
+ reactionTTL: 900,
340
386
  bubbleStyle: "classic",
341
387
  bubblePosition: "top",
342
388
  showRarity: true,
343
389
  statusLineEnabled: false,
390
+ statuslineDensity: "auto",
391
+ statuslineWidthAdjust: 0,
344
392
  bubbleWidth: 28,
345
393
  bubbleMargin: 8,
346
394
  useCombinedStatus: false,
@@ -363,22 +411,32 @@ export function normalizeConfig(data: unknown): BuddyConfig {
363
411
  if (!Number.isInteger(config.subStatusRefreshSeconds) || config.subStatusRefreshSeconds <= 0) {
364
412
  config.subStatusRefreshSeconds = DEFAULT_CONFIG.subStatusRefreshSeconds;
365
413
  }
414
+ if (!Number.isInteger(config.reactionTTL) || config.reactionTTL < 0) {
415
+ config.reactionTTL = DEFAULT_CONFIG.reactionTTL;
416
+ }
417
+ if (!Number.isInteger(config.statuslineWidthAdjust)) {
418
+ config.statuslineWidthAdjust = DEFAULT_CONFIG.statuslineWidthAdjust;
419
+ }
420
+ const allowedDensities: BuddyConfig["statuslineDensity"][] = ["auto", "full", "compact", "minimal"];
421
+ if (!allowedDensities.includes(config.statuslineDensity)) {
422
+ config.statuslineDensity = DEFAULT_CONFIG.statuslineDensity;
423
+ }
366
424
  return config;
367
425
  }
368
426
 
369
427
  export function loadConfig(): BuddyConfig {
370
428
  try {
371
- return normalizeConfig(JSON.parse(readFileSync(CONFIG_FILE, "utf8")));
429
+ return normalizeConfig(JSON.parse(readFileSync(CONFIG_FILE(), "utf8")));
372
430
  } catch {
373
431
  return normalizeConfig({});
374
432
  }
375
433
  }
376
434
 
377
435
  export function saveConfig(config: Partial<BuddyConfig>): BuddyConfig {
378
- mkdirSync(STATE_DIR, { recursive: true });
436
+ mkdirSync(stateDir(), { recursive: true });
379
437
  const current = loadConfig();
380
438
  const merged = { ...current, ...config };
381
- writeFileSync(CONFIG_FILE, JSON.stringify(merged, null, 2));
439
+ writeFileSync(CONFIG_FILE(), JSON.stringify(merged, null, 2));
382
440
  return merged;
383
441
  }
384
442
 
@@ -397,12 +455,36 @@ export interface StatusState {
397
455
  muted: boolean;
398
456
  achievement: string;
399
457
  frames: string[];
458
+ compactFrames: string[];
459
+ minimalFrames: string[];
400
460
  frameSequence: number[];
401
461
  level: number;
402
462
  xp: number;
403
463
  mood: string;
404
464
  }
405
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
+ }
406
488
  export function writeStatusState(
407
489
  companion: Companion,
408
490
  reaction?: string,
@@ -411,17 +493,32 @@ export function writeStatusState(
411
493
  level?: number,
412
494
  xp?: number,
413
495
  ): void {
414
- mkdirSync(STATE_DIR, { recursive: true });
415
- const { renderFace, RARITY_STARS } =
416
- require("../core/engine.ts") as typeof import("../core/engine.ts");
496
+ mkdirSync(stateDir(), { recursive: true });
497
+ const { renderFace, RARITY_STARS, resolveEyeGlyph } =
498
+ require("../core/engine.ts") as typeof CoreEngine;
417
499
  const { getStatusFrames } =
418
- require("./art.ts") as typeof import("./art.ts");
419
- const { frames, frameSequence } = getStatusFrames(companion.bones);
500
+ require("./art.ts") as typeof Art;
501
+ const safeEye = resolveEyeGlyph(companion.bones.eye);
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);
420
517
  let xpLevel = level ?? 1;
421
518
  let xpTotal = xp ?? 0;
422
519
  let moodStr = "focused";
423
520
  try {
424
- const { getXpState } = require("./xp.ts") as typeof import("./xp.ts");
521
+ const { getXpState } = require("./xp.ts") as typeof XpModule;
425
522
  const xpState = getXpState();
426
523
  xpLevel = xpState.level;
427
524
  xpTotal = xpState.totalXp;
@@ -429,7 +526,7 @@ export function writeStatusState(
429
526
  // XP state is optional during first install / version skew.
430
527
  }
431
528
  try {
432
- const { getMood } = require("./mood.ts") as typeof import("./mood.ts");
529
+ const { getMood } = require("./mood.ts") as typeof MoodModule;
433
530
  moodStr = getMood().current;
434
531
  } catch {
435
532
  // Mood state is optional during first install / version skew.
@@ -439,27 +536,36 @@ export function writeStatusState(
439
536
  species: companion.bones.species,
440
537
  rarity: companion.bones.rarity,
441
538
  stars: RARITY_STARS[companion.bones.rarity],
442
- face: renderFace(companion.bones.species, companion.bones.eye),
443
- eye: companion.bones.eye,
539
+ face: renderFace(companion.bones.species, safeEye),
540
+ eye: safeEye,
444
541
  shiny: companion.bones.shiny,
445
542
  hat: companion.bones.hat,
446
543
  reaction: "",
447
544
  muted: muted ?? false,
448
545
  achievement: achievement ?? "",
449
546
  frames,
547
+ compactFrames,
548
+ minimalFrames,
450
549
  frameSequence,
451
550
  level: xpLevel,
452
551
  xp: xpTotal,
453
552
  mood: moodStr,
454
553
  };
455
- writeFileSync(join(STATE_DIR, "status.json"), JSON.stringify(state));
456
- if (reaction) saveReaction(reaction, "mcp");
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());
559
+ // writeStatusState no longer calls saveReaction implicitly. Callers
560
+ // that need a reaction file do so explicitly with the correct
561
+ // `source` tag — the old implicit save had no source argument and
562
+ // its default later flipped from "tool" to "fallback", either way
563
+ // overwriting provenance of hatch / pet / unmute writes with whatever
564
+ // happened to be the default. /buddy stats stopped lying when
565
+ // this call was removed.
457
566
  }
458
567
 
459
- // ─── Claude Code settings.json patching (for buddy_statusline tool) ──────────
460
-
461
568
  export const CLAUDE_SETTINGS_PATH = claudeSettingsPath();
462
-
463
569
  /**
464
570
  * Write settings.statusLine pointing to the given buddy-status script.
465
571
  * Atomic via tmp + rename. Returns false if settings.json is unreachable.
@@ -534,11 +640,11 @@ const TRANSIENT_PREFIXES = [
534
640
  *
535
641
  * Companion data (menagerie.json, status.json, config.json) is intentionally
536
642
  * kept — users reinstalling get their buddy back. Call-sites that want a full
537
- * wipe should delete STATE_DIR themselves after calling this.
643
+ * wipe should delete stateDir() themselves after calling this.
538
644
  */
539
645
  export function cleanupPluginState(
540
646
  settingsPath: string = CLAUDE_SETTINGS_PATH,
541
- stateDir: string = STATE_DIR,
647
+ stateDir: string = buddyStateDir(),
542
648
  ): CleanupResult {
543
649
  const statusLineRemoved = unsetBuddyStatusLine(settingsPath);
544
650
 
@@ -86,7 +86,7 @@ The MCP tools return pre-formatted ASCII art with ANSI colors, box-drawing chara
86
86
 
87
87
  **Just output the raw text content from the tool result. Nothing else.** The ASCII art IS the response.
88
88
 
89
- If the user mentions the buddy's name in normal conversation, call `buddy_react` with reason "turn" and display the result verbatim.
89
+ If the user mentions the buddy's name in normal conversation, call `buddy_react` with reason "turn". Do NOT echo or quote the tool result — the reaction reaches the user only via the statusline speech bubble.
90
90
 
91
91
  ## Uninstall Orchestration
92
92