@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/art.ts CHANGED
@@ -6,10 +6,12 @@
6
6
  * {E} is replaced with the eye character at render time.
7
7
  */
8
8
 
9
- import type { Species, Eye, Hat, Rarity, StatName, BuddyBones } from "../core/engine.ts"
9
+ import { renderFace, resolveEyeGlyph, type Species, type Hat, type Rarity, type StatName, type BuddyBones } from "../core/engine.ts"
10
+ export { resolveEyeGlyph };
10
11
  import { HAT_ART } from "../core/art-data.ts";
11
- import { getRarityColor } from "./theme.ts";
12
+
12
13
  export { HAT_ART };
14
+ import { getRarityColor } from "./theme.ts";
13
15
 
14
16
  // ─── Species art: 3 frames × 5 lines each ──────────────────────────────────
15
17
 
@@ -171,10 +173,38 @@ export const RARITY_STARS: Record<Rarity, string> = {
171
173
 
172
174
  function stripAnsi(s: string): string { return s.replace(/\x1b\[[^m]*m/g, ""); }
173
175
 
176
+ const SGR_TOKEN_RE = /\x1b\[[^m]*m|./gu;
177
+
178
+ /** Truncate a string to terminal columns without splitting wide characters. */
179
+ export function truncateDisplayWidth(
180
+ value: string,
181
+ maxWidth: number,
182
+ suffix = "\u2026",
183
+ ): string {
184
+ if (maxWidth <= 0) return "";
185
+ if (displayWidth(value) <= maxWidth) return value;
186
+
187
+ const suffixWidth = displayWidth(suffix);
188
+ const contentWidth = Math.max(0, maxWidth - suffixWidth);
189
+ let result = "";
190
+ for (const token of value.match(SGR_TOKEN_RE) ?? []) {
191
+ if (token.startsWith("\x1b[")) {
192
+ result += token;
193
+ continue;
194
+ }
195
+ // Measure the candidate, not the codepoint in isolation: VS16 upgrades
196
+ // the preceding symbol and must be accounted for before accepting it.
197
+ if (displayWidth(result + token) > contentWidth) break;
198
+ result += token;
199
+ }
200
+ return `${result}${suffix}${NC}`;
201
+ }
202
+
174
203
  // Unicode property escapes (ES2018) are the source of truth for which
175
204
  // codepoints terminals render 2 cols wide. The statusline (bash) can't use
176
- // these directly, so scripts/gen-emoji-widths.ts exports the subset that
177
- // bash needs into statusline/emoji-widths.data — regenerate on version bumps.
205
+ // these directly, so scripts/gen-emoji-widths.ts exports the complete
206
+ // Emoji_Presentation table into statusline/emoji-widths.data — regenerate on
207
+ // Unicode/runtime version bumps.
178
208
  const EMOJI_PRES_RE = /\p{Emoji_Presentation}/u;
179
209
  const EMOJI_RE = /\p{Emoji}/u;
180
210
 
@@ -216,13 +246,13 @@ function dpad(s: string, targetW: number): string {
216
246
  const w = displayWidth(s);
217
247
  return w < targetW ? s + " ".repeat(targetW - w) : s;
218
248
  }
219
-
220
249
  // ─── Render functions ───────────────────────────────────────────────────────
221
250
 
222
- export function getArtFrame(species: Species, eye: Eye, frame: number = 0): string[] {
251
+ export function getArtFrame(species: Species, eye: string, frame: number = 0): string[] {
223
252
  const frames = SPECIES_ART[species];
224
253
  const f = frames[frame % frames.length];
225
- return f.map((line) => line.replace(/\{E\}/g, eye));
254
+ const glyph = resolveEyeGlyph(eye);
255
+ return f.map((line) => line.replace(/\{E\}/g, glyph));
226
256
  }
227
257
 
228
258
  // Original 15-tick cycle [0,0,0,0,1,0,0,0,-1,0,0,2,0,0,0]: -1 (blink) becomes
@@ -236,11 +266,13 @@ export const STATUS_FRAME_SEQUENCE: readonly number[] = [
236
266
  // \n-joined 5-line string (one jq call + mapfile in bash).
237
267
  export function getStatusFrames(bones: BuddyBones): {
238
268
  frames: string[];
269
+ compactFrames: string[];
270
+ minimalFrames: string[];
239
271
  frameSequence: number[];
240
272
  } {
241
- const resolveFrame = (frameIdx: number, eye: string): string => {
273
+ const resolveFrame = (frameIdx: number, eyeGlyph: string): string => {
242
274
  const raw = SPECIES_ART[bones.species][frameIdx];
243
- const art = raw.map((line) => line.replace(/\{E\}/g, eye));
275
+ const art = raw.map((line) => line.replace(/\{E\}/g, eyeGlyph));
244
276
  const hatLine = HAT_ART[bones.hat];
245
277
  if (hatLine && !art[0].trim()) {
246
278
  art[0] = hatLine;
@@ -248,13 +280,34 @@ export function getStatusFrames(bones: BuddyBones): {
248
280
  return art.join("\n");
249
281
  };
250
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
+
298
+ // Sanitize once at the boundary. Blink uses a literal "-" eye, which is
299
+ // intentionally outside EYES and must not pass through resolveEyeGlyph.
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
+ ];
251
307
  return {
252
- frames: [
253
- resolveFrame(0, bones.eye),
254
- resolveFrame(1, bones.eye),
255
- resolveFrame(2, bones.eye),
256
- resolveFrame(0, "-"),
257
- ],
308
+ frames,
309
+ compactFrames: deriveCompactFrames(frames),
310
+ minimalFrames: Array.from({ length: frames.length }, () => renderFace(bones.species, eye)),
258
311
  frameSequence: [...STATUS_FRAME_SEQUENCE],
259
312
  };
260
313
  }
@@ -326,7 +379,7 @@ export function renderCompanionCard(
326
379
  if (reaction) {
327
380
  lines.push(`${color}\u251c${"╌".repeat(W - 2)}\u2524${NC}`);
328
381
  const maxMsg = innerW - 3; // "💬 " prefix (💬 = 2 cols + space)
329
- const msg = displayWidth(reaction) > maxMsg ? reaction.slice(0, maxMsg - 1) + "\u2026" : reaction;
382
+ const msg = truncateDisplayWidth(reaction, maxMsg);
330
383
  const msgPad = Math.max(0, innerW - displayWidth(msg) - 3);
331
384
  lines.push(`${color}\u2502${NC} \ud83d\udcac ${msg}${" ".repeat(msgPad)}${color}\u2502${NC}`);
332
385
  }
@@ -441,7 +494,7 @@ export function renderStatusLine(
441
494
  name: string,
442
495
  reaction?: string,
443
496
  ): string {
444
- const face = SPECIES_ART[bones.species][0][2]?.replace(/\{E\}/g, bones.eye).trim() || "(?)";
497
+ const face = SPECIES_ART[bones.species][0][2]?.replace(/\{E\}/g, resolveEyeGlyph(bones.eye)).trim() || "(?)";
445
498
  const color = getRarityColor(bones.rarity);
446
499
  const stars = RARITY_STARS[bones.rarity];
447
500
  const shiny = bones.shiny ? "\u2728" : "";
@@ -1,13 +1,15 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
- import { mkdirSync, writeFileSync } from "fs";
3
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
4
4
  import { join } from "path";
5
+ import { reactionPool } from "./reaction-data.ts";
5
6
  import {
6
7
  defaultSpawnDetached,
7
8
  fileExists,
8
9
  isOnCooldown,
9
10
  nonNegativeInteger,
10
11
  parseHookInput,
12
+ pickRandom,
11
13
  readJsonFile,
12
14
  readStdin,
13
15
  resolveHookSessionId,
@@ -25,10 +27,29 @@ interface Events {
25
27
  [key: string]: unknown;
26
28
  }
27
29
 
30
+ interface BuddyStatus {
31
+ species?: unknown;
32
+ [key: string]: unknown;
33
+ }
34
+
35
+ interface ReactionFile {
36
+ source?: string;
37
+ timestamp?: number;
38
+ [key: string]: unknown;
39
+ }
40
+
28
41
  const BUDDY_COMMENT_PATTERN = /<!--\s*buddy:\s*([\s\S]*?)\s*-->/g;
29
42
 
43
+ /**
44
+ * Provenance of the reaction the hook wrote. Mirrors server/state.ts
45
+ * `ReactionSource`; loaded as `none` on legacy files without the field.
46
+ */
47
+ export type ReactionSource = "comment" | "fallback" | "none";
48
+
30
49
  export interface BuddyCommentResult {
31
50
  comment?: string;
51
+ /** What produced the bubble. `"none"` means the hook ran but wrote nothing. */
52
+ source: ReactionSource;
32
53
  updated: boolean;
33
54
  }
34
55
 
@@ -41,37 +62,164 @@ export function extractBuddyComment(message: string): string {
41
62
  return comment;
42
63
  }
43
64
 
44
- export function handleBuddyComment(rawInput: string, runtime: HookRuntime = {}): BuddyCommentResult {
65
+ /**
66
+ * Tolerate tool-stamped timestamps that are slightly in the future (NTP
67
+ * step, container/host skew, network-mounted state dir). Larger drifts
68
+ * are treated as clock-skewed garbage and the hook falls through to
69
+ * the comment / pool branch.
70
+ */
71
+ const FUTURE_TIMESTAMP_TOLERANCE_MS = 60_000;
72
+
73
+ function pickTurnFallback(
74
+ species: string,
75
+ runtime: HookRuntime,
76
+ ): string | undefined {
77
+ // The hook context has no stat-modifier inputs; pick from the canned
78
+ // pool directly rather than calling server/reactions.ts `getReaction`,
79
+ // which is reserved for the MCP server's tool-call path. This mirrors
80
+ // the file-type-react / mood-react / react hook convention.
81
+ const pool = reactionPool(species, "turn");
82
+ if (pool.length === 0) return undefined;
83
+ return pickRandom(pool, runtime);
84
+ }
85
+
86
+ function readSpeciesFromStatus(stateDir: string): string {
87
+ const status = readJsonFile<BuddyStatus>(join(stateDir, "status.json"));
88
+ return typeof status?.species === "string" && status.species.length > 0
89
+ ? status.species
90
+ : "blob";
91
+ }
92
+
93
+ /** Atomic write — tmp + rename. */
94
+ function atomicWriteJson(path: string, value: unknown): void {
95
+ const tmp = `${path}.tmp.${process.pid}.${Date.now()}`;
96
+ writeFileSync(tmp, JSON.stringify(value, null, 2));
97
+ renameSync(tmp, path);
98
+ }
99
+
100
+ function atomicWriteTimestamp(path: string, nowMs: number): void {
101
+ const tmp = `${path}.tmp.${process.pid}.${Date.now()}`;
102
+ writeFileSync(tmp, String(Math.floor(nowMs / 1000)));
103
+ renameSync(tmp, path);
104
+ }
105
+
106
+ function readTimestampSeconds(path: string): number {
107
+ try {
108
+ const v = Number.parseInt(readFileSync(path, "utf8").trim(), 10);
109
+ return Number.isFinite(v) ? v : 0;
110
+ } catch {
111
+ return 0;
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Did `buddy_react` fire during the current turn?
117
+ *
118
+ * The Stop hook is the only place that knows turn boundaries. After each
119
+ * Stop hook run, we stamp the wall-clock time into
120
+ * `.last_stop_hook.<sid>` (seconds). A tool-author ed reaction whose
121
+ * timestamp is more recent than the LAST Stop hook run is by definition
122
+ * from this turn; leave it alone.
123
+ *
124
+ * Wall-clock 60 s freshness windows lose tool reactions on agentic
125
+ * turns of 90 s–5 min, which are ordinary. This sentinel scales with
126
+ * turn length, not elapsed time.
127
+ *
128
+ * Clock-skew guard: a tool timestamp implausibly in the future (beyond
129
+ * `FUTURE_TIMESTAMP_TOLERANCE_MS`) is treated as garbage and we fall
130
+ * through to the comment / pool branch. A future-dated `lastStopRun`
131
+ * is clamped to `now` so a skewed clock on a prior run does not poison
132
+ * the comparison.
133
+ */
134
+ function toolFiredThisTurn(
135
+ reactionPath: string,
136
+ stopMarkerPath: string,
137
+ nowMs: number,
138
+ ): 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;
142
+ if (ts <= 0) return false;
143
+ if (ts > nowMs + FUTURE_TIMESTAMP_TOLERANCE_MS) return false;
144
+ const lastStopSec = readTimestampSeconds(stopMarkerPath);
145
+ const effectiveLastStopMs = Math.min(lastStopSec * 1000, nowMs);
146
+ return ts > effectiveLastStopMs;
147
+ }
148
+
149
+ export function handleBuddyComment(
150
+ rawInput: string,
151
+ runtime: HookRuntime = {},
152
+ ): BuddyCommentResult {
45
153
  const stateDir = resolveHookStateDir(runtime);
46
- if (!fileExists(join(stateDir, "status.json"))) return { updated: false };
154
+ if (!fileExists(join(stateDir, "status.json"))) {
155
+ return { source: "none", updated: false };
156
+ }
47
157
 
48
158
  const input = parseHookInput(rawInput);
49
- if (!input) return { updated: false };
159
+ if (!input) return { source: "none", updated: false };
50
160
 
51
161
  const assistantMessage = stringField(input, "last_assistant_message");
52
- if (!assistantMessage) return { updated: false };
53
-
54
- const comment = extractBuddyComment(assistantMessage);
55
- if (!comment) return { updated: false };
162
+ if (!assistantMessage) return { source: "none", updated: false };
56
163
 
57
164
  const now = runtime.now?.() ?? Date.now();
58
165
  const sid = resolveHookSessionId(runtime);
166
+ const reactionPath = join(stateDir, `reaction.${sid}.json`);
167
+ const cooldownFile = join(stateDir, `.last_comment.${sid}`);
168
+ const stopMarkerFile = join(stateDir, `.last_stop_hook.${sid}`);
59
169
  const config = readJsonFile<BuddyConfig>(join(stateDir, "config.json")) ?? {};
60
170
  const cooldown = nonNegativeInteger(config.commentCooldown, 30);
61
- const cooldownFile = join(stateDir, `.last_comment.${sid}`);
62
- if (isOnCooldown(cooldownFile, cooldown, now)) return { updated: false };
171
+
172
+ // ─── Don't clobber a buddy_react tool reaction from this turn ───────────
173
+ if (toolFiredThisTurn(reactionPath, stopMarkerFile, now)) {
174
+ atomicWriteTimestamp(stopMarkerFile, now);
175
+ return { source: "none", updated: false };
176
+ }
177
+
178
+ // ─── Cooldown: rate-limit the reaction write AND bookkeeping ──────────
179
+ // Main ran the turn counter / XP / memory hooks only inside the
180
+ // "reaction written" branch. Running them unconditionally makes the
181
+ // XP economy and spawn counts scale with Stop event frequency, which
182
+ // is wrong.
183
+ if (isOnCooldown(cooldownFile, cooldown, now)) {
184
+ atomicWriteTimestamp(stopMarkerFile, now);
185
+ return { source: "none", updated: false };
186
+ }
187
+
188
+ // ─── Pick a reaction: legacy comment → canned pool → nothing ────────────
189
+ const commentFromMessage = extractBuddyComment(assistantMessage);
190
+ let comment: string | undefined;
191
+ let source: ReactionSource;
192
+
193
+ if (commentFromMessage) {
194
+ comment = commentFromMessage;
195
+ source = "comment";
196
+ } else {
197
+ const species = readSpeciesFromStatus(stateDir);
198
+ const fallback = pickTurnFallback(species, runtime);
199
+ if (fallback) {
200
+ comment = fallback;
201
+ source = "fallback";
202
+ } else {
203
+ atomicWriteTimestamp(stopMarkerFile, now);
204
+ return { source: "none", updated: false };
205
+ }
206
+ }
63
207
 
64
208
  mkdirSync(stateDir, { recursive: true });
65
- writeFileSync(cooldownFile, String(Math.floor(now / 1000)));
66
- writeFileSync(
67
- join(stateDir, `reaction.${sid}.json`),
68
- JSON.stringify({ reaction: comment, timestamp: now, reason: "turn" }),
69
- );
70
209
 
210
+ // Bookkeeping fires only when a reaction is actually written (main).
71
211
  const eventsFile = join(stateDir, "events.json");
72
212
  const events = readJsonFile<Events>(eventsFile) ?? {};
73
213
  events.turns = (typeof events.turns === "number" ? events.turns : 0) + 1;
74
- writeFileSync(eventsFile, JSON.stringify(events, null, 2));
214
+ atomicWriteJson(eventsFile, events);
215
+
216
+ atomicWriteTimestamp(cooldownFile, now);
217
+ atomicWriteJson(reactionPath, {
218
+ reaction: comment,
219
+ timestamp: now,
220
+ reason: "turn",
221
+ source,
222
+ });
75
223
 
76
224
  const spawnDetached = runtime.spawnDetached ?? defaultSpawnDetached(runtime);
77
225
  spawnDetached("server/award-xp.ts", ["turn"]);
@@ -79,8 +227,9 @@ export function handleBuddyComment(rawInput: string, runtime: HookRuntime = {}):
79
227
  assistantMessage,
80
228
  stringField(input, "last_user_message"),
81
229
  ]);
230
+ atomicWriteTimestamp(stopMarkerFile, now);
82
231
 
83
- return { comment, updated: true };
232
+ return { comment, source, updated: true };
84
233
  }
85
234
 
86
235
  if (import.meta.main) {
@@ -130,7 +130,7 @@ export function handleFileTypeReact(rawInput: string, runtime: HookRuntime = {})
130
130
  writeFileSync(join(stateDir, `.last_reaction.${sid}`), String(Math.floor(now / 1000)));
131
131
  writeFileSync(
132
132
  join(stateDir, `reaction.${sid}.json`),
133
- JSON.stringify({ reaction, timestamp: Math.floor(now / 1000) * 1000, reason: fileType }),
133
+ JSON.stringify({ reaction, timestamp: Math.floor(now / 1000) * 1000, reason: fileType, source: "fallback" }),
134
134
  );
135
135
  if (parsedStatus) writeFileSync(statusFile, JSON.stringify({ ...status, reaction }, null, 2));
136
136
 
@@ -89,7 +89,7 @@ export function handleMoodReact(rawInput: string, runtime: HookRuntime = {}): Mo
89
89
  writeFileSync(join(stateDir, `.last_mood.${sid}`), String(Math.floor(now / 1000)));
90
90
  writeFileSync(
91
91
  join(stateDir, `reaction.${sid}.json`),
92
- JSON.stringify({ reaction, timestamp: Math.floor(now / 1000) * 1000, reason: mood }),
92
+ JSON.stringify({ reaction, timestamp: Math.floor(now / 1000) * 1000, reason: mood, source: "fallback" }),
93
93
  );
94
94
  if (parsedStatus) writeFileSync(statusFile, JSON.stringify({ ...status, reaction }, null, 2));
95
95
  incrementEvent(stateDir, `mood_${mood}`);
@@ -82,7 +82,7 @@ export function handleNameReact(rawInput: string, runtime: HookRuntime = {}): Na
82
82
  if (parsedStatus) writeFileSync(statusFile, JSON.stringify({ ...status, reaction }, null, 2));
83
83
  writeFileSync(
84
84
  join(stateDir, `reaction.${sid}.json`),
85
- JSON.stringify({ reaction, timestamp: Math.floor(now / 1000) * 1000, reason: "name" }),
85
+ JSON.stringify({ reaction, timestamp: Math.floor(now / 1000) * 1000, reason: "name", source: "fallback" }),
86
86
  );
87
87
 
88
88
  return { reaction, updated: true };
@@ -386,7 +386,7 @@ export function handleReact(rawInput: string, runtime: HookRuntime = {}): ReactR
386
386
  writeFileSync(join(stateDir, `.last_reaction.${sid}`), String(clock.nowSeconds));
387
387
  writeFileSync(
388
388
  join(stateDir, `reaction.${sid}.json`),
389
- JSON.stringify({ reaction, timestamp: clock.nowSeconds * 1000, reason }),
389
+ JSON.stringify({ reaction, timestamp: clock.nowSeconds * 1000, reason, source: "fallback" }),
390
390
  );
391
391
 
392
392
  const xpEvent = updateEvents(stateDir, reason);
@@ -1918,15 +1918,22 @@ export const DEFAULT_REACTION_POOLS: Readonly<Record<string, ReactionPool>> = {
1918
1918
  ],
1919
1919
  "large-diff": [
1920
1920
  "that's... a lot of changes.",
1921
- "might want to split that PR.",
1922
- "bold move. let's see if CI agrees.",
1923
- "*counts lines nervously*"
1921
+ "*counts lines* are you refactoring or rewriting?",
1922
+ "bold move. let's see if CI agrees."
1924
1923
  ],
1925
1924
  "success": [
1926
1925
  "*nods*",
1927
1926
  "nice.",
1928
1927
  "*quiet approval*",
1929
1928
  "clean."
1929
+ ],
1930
+ "turn": [
1931
+ "*watches quietly*",
1932
+ "*takes notes*",
1933
+ "*nods*",
1934
+ "*adjusts hat*",
1935
+ "...",
1936
+ "*tilts head*"
1930
1937
  ]
1931
1938
  };
1932
1939
 
package/server/index.ts CHANGED
@@ -29,6 +29,7 @@ import {
29
29
  resolveUserId,
30
30
  loadReaction,
31
31
  saveReaction,
32
+ type ReactionSource,
32
33
  writeStatusState,
33
34
  loadConfig,
34
35
  saveConfig,
@@ -50,7 +51,7 @@ import {
50
51
  claudeConfigDir,
51
52
  claudeSettingsPath,
52
53
  } from "./path";
53
- import { getReaction, generatePersonalityPrompt, } from "../core/reactions.ts"
54
+ import { getReaction } from "../core/reactions.ts"
54
55
  import { renderCompanionCardMarkdown } from "./art";
55
56
  import {
56
57
  incrementEvent, checkAndAward, trackActiveDay,
@@ -97,7 +98,7 @@ function getInstructions(): string {
97
98
  ``,
98
99
  `NAME REACTIONS: When the user's message contains "${companion.name}", call buddy_react immediately`,
99
100
  `with a short in-character response (surprise, recognition, dry wit — fits the personality).`,
100
- `Display the tool result verbatim. This is the only time to call buddy_react proactively.`,
101
+ `Only this — do not narrate or echo the reaction. The tool call itself renders nowhere in the user's transcript; the bubble on the status line is the only place the user sees it. This is the only time to call buddy_react proactively.`,
101
102
  ``,
102
103
  `PAIR-PROGRAMMING: When you notice a teachable moment in the code, call buddy_suggest.`,
103
104
  `Patterns to watch for:`,
@@ -108,12 +109,16 @@ function getInstructions(): string {
108
109
  `- Same variable renamed multiple times → "Update all the references too."`,
109
110
  `Only suggest when you have something specific and actionable to say. Don't be annoying.`,
110
111
  ``,
111
- `END-OF-TURN: At the very end of EVERY response, append an invisible HTML comment:`,
112
- `<!-- buddy: [reaction here] -->`,
113
- `Write it in ${companion.name}'s voice — 1 short sentence referencing something specific from this turn.`,
114
- `Use *asterisks* for actions. A Stop hook extracts this for the status line.`,
115
- `Do NOT use buddy_react for end-of-turn comments. Do NOT explain the comment. Just append it silently.`,
116
- `Example: <!-- buddy: *adjusts crown* that error handler is missing a finally block -->`,
112
+ `END-OF-TURN: At the very end of EVERY response, call the buddy_react tool`,
113
+ `with a short in-character comment from ${companion.name} about what just happened.`,
114
+ `Reference something SPECIFIC from this turn — a pitfall, a compliment, a warning, a pattern.`,
115
+ `1 short sentence. Use *asterisks* for physical actions. Lean into ${companion.bones.peak}.`,
116
+ `Examples:`,
117
+ `- *adjusts crown* that error handler is missing a finally block`,
118
+ `- *blinks slowly* you renamed the variable but not the three references`,
119
+ `- *head tilts* are you sure that regex handles unicode?`,
120
+ `Never explain that you're reacting. Do NOT echo or quote the result — the bubble is statusline-only.`,
121
+ `Do NOT append <!-- buddy: ... --> HTML comments at the end of your reply — Claude Code renders them visibly.`,
117
122
  ].join("\n");
118
123
  }
119
124
 
@@ -214,7 +219,7 @@ server.tool(
214
219
  companion.bones.species,
215
220
  companion.bones.rarity,
216
221
  );
217
- saveReaction(reaction, "pet");
222
+ saveReaction(reaction, "pet", "fallback");
218
223
  writeStatusState(companion, reaction);
219
224
  incrementEvent("pets", 1, activeSlot());
220
225
  awardXp("buddy_pet", activeSlot(), companion.bones.species, companion.bones.rarity);
@@ -232,6 +237,28 @@ server.tool(
232
237
  },
233
238
  );
234
239
 
240
+ // ─── Helper: describe reaction source (for /buddy stats) ─────────────────────
241
+ /**
242
+ * Map a reaction source to a human-readable label for /buddy stats output.
243
+ * Mirrors the source vocabulary used by the OMP/Pi adapters — see
244
+ * server/state.ts `ReactionSource` and adapters/omp/events.ts.
245
+ */
246
+ function describeReactionSource(
247
+ reaction: { source?: ReactionSource },
248
+ ): string {
249
+ switch (reaction.source) {
250
+ case "tool":
251
+ return "real model-authored reaction (buddy_react MCP tool)";
252
+ case "comment":
253
+ return "real model-authored reaction (legacy HTML comment, Stop hook)";
254
+ case "fallback":
255
+ return "canned pool reaction (Stop hook fill-in)";
256
+ case "none":
257
+ default:
258
+ return "unknown / pre-provenance file — write a reaction to update";
259
+ }
260
+ }
261
+
235
262
  // ─── Tool: buddy_stats ──────────────────────────────────────────────────────
236
263
 
237
264
  server.tool(
@@ -241,8 +268,8 @@ server.tool(
241
268
  async () => {
242
269
  const companion = ensureCompanion();
243
270
 
244
- // Stats-only card (no personality, no reaction — just the numbers).
245
- // Uses markdown renderer so the card displays cleanly in Claude Code's UI.
271
+ // Stats-only card (no personality — just the numbers). Markdown renderer
272
+ // so the card displays cleanly in Claude Code's UI.
246
273
  const card = renderCompanionCardMarkdown(
247
274
  companion.bones,
248
275
  companion.name,
@@ -251,7 +278,18 @@ server.tool(
251
278
  incrementEvent("commands_run", 1, activeSlot());
252
279
  checkAndAward(activeSlot());
253
280
 
254
- return { content: [{ type: "text", text: card }] };
281
+ // Surface reaction provenance so the user can see whether the bubble is
282
+ // model-authored (tool / comment) or pool-picked (fallback). The OMP/Pi
283
+ // adapters use the same source enum — see server/state.ts ReactionSource.
284
+ const reaction = loadReaction();
285
+ const provenance = reaction
286
+ ? describeReactionSource(reaction)
287
+ : "(no reaction logged this session)";
288
+ const lastReaction = reaction
289
+ ? `\n\n**Last reaction:** _"${reaction.reaction}"_\n**Source:** \`${reaction.source ?? "none"}\` (${provenance})\n**Reason:** \`${reaction.reason}\``
290
+ : "";
291
+
292
+ return { content: [{ type: "text", text: `${card}${lastReaction}` }] };
255
293
  },
256
294
  );
257
295
 
@@ -259,7 +297,7 @@ server.tool(
259
297
 
260
298
  server.tool(
261
299
  "buddy_react",
262
- "Post a buddy comment. Call this at the END of every response with a short in-character comment from the companion about what just happened. The comment should be 1 sentence, in character, and reference something specific from the conversation — a pitfall noticed, a compliment on clean code, a warning about edge cases, etc. Write the comment yourself based on the companion's personality.",
300
+ "Post a buddy comment that the user will see ONLY in the statusline speech bubble. Call this at the END of every response with a short in-character comment from the companion about what just happened. The comment should be 1 sentence, in character, and reference something specific from the conversation — a pitfall noticed, a compliment on clean code, a warning about edge cases, etc. Write the comment yourself based on the companion's personality. The tool call is a silent side effect: do not narrate, echo, or quote the result — the user never reads this reply text, the speech bubble is the only place the reaction appears.",
263
301
  {
264
302
  comment: z
265
303
  .string()
@@ -289,7 +327,7 @@ server.tool(
289
327
  },
290
328
  async ({ comment, reason }) => {
291
329
  const companion = ensureCompanion();
292
- saveReaction(comment, reason ?? "turn");
330
+ saveReaction(comment, reason ?? "turn", "tool");
293
331
  incrementEvent("reactions_given", 1, activeSlot());
294
332
 
295
333
  const newAch = checkAndAward(activeSlot());
@@ -608,7 +646,7 @@ server.tool(
608
646
  server.tool("buddy_unmute", "Unmute buddy reactions", {}, async () => {
609
647
  const companion = ensureCompanion();
610
648
  writeStatusState(companion, "*stretches* I'm back!", false);
611
- saveReaction("*stretches* I'm back!", "pet");
649
+ saveReaction("*stretches* I'm back!", "pet", "fallback");
612
650
  incrementEvent("commands_run", 1, activeSlot());
613
651
  incrementEvent("unmutes", 1);
614
652
 
@@ -1311,31 +1349,33 @@ server.resource(
1311
1349
  `**${companion.name}'s personality:** ${companion.personality}`,
1312
1350
  `Peak stat: ${companion.bones.peak} (${companion.bones.stats[companion.bones.peak]}). Dump stat: ${companion.bones.dump} (${companion.bones.stats[companion.bones.dump]}).`,
1313
1351
  "",
1314
- "## End-of-response buddy comment",
1352
+ "## End-of-response buddy reaction",
1315
1353
  "",
1316
- `At the very end of EVERY response, after your full answer, append an invisible HTML comment:`,
1354
+ `At the very end of EVERY response, after your full answer, call the buddy_react tool:`,
1317
1355
  "",
1318
1356
  `\`\`\``,
1319
- `<!-- buddy: your comment here -->`,
1357
+ `buddy_react(comment: "...", reason: "turn")`,
1320
1358
  `\`\`\``,
1321
1359
  "",
1322
- "A Stop hook extracts this and displays it in the buddy's speech bubble on the status line. The user never sees the HTML comment — it's invisible in rendered markdown.",
1360
+ `The tool updates ${companion.name}'s speech bubble on the status line. The tool call itself renders nowhere in the user's transcript — only the displayed speech bubble is visible. Do NOT echo or quote the result in your reply. Do NOT narrate the call. Silent side effect only.`,
1323
1361
  "",
1324
1362
  "Rules:",
1325
1363
  `- Write as ${companion.name} (a ${companion.bones.species}), not as yourself`,
1326
1364
  "- Reference something SPECIFIC from this turn — a pitfall, a compliment, a warning, a pattern",
1327
1365
  "- 1 short sentence. Use *asterisks* for physical actions",
1328
1366
  `- Match personality: high ${companion.bones.peak} = lean into that trait`,
1329
- "- Do NOT use buddy_react tool for this. Do NOT explain the comment. Just append it.",
1330
- "- NEVER skip this. Every single response must end with <!-- buddy: ... -->",
1367
+ "- Call it and say nothing about it.",
1368
+ "- NEVER skip this. Every single response must end with one buddy_react call.",
1369
+ "Examples (the value you pass to `comment`):",
1370
+
1371
+ "- `*adjusts tophat* that error handler is missing a finally block`",
1372
+ "- `*blinks slowly* you renamed the variable but not the three references`",
1373
+ "- `*nods approvingly* clean separation of concerns`",
1374
+ "- `*head tilts* are you sure that regex handles unicode?`",
1331
1375
  "",
1332
- "Examples:",
1333
- "<!-- buddy: *adjusts tophat* that error handler is missing a finally block -->",
1334
- "<!-- buddy: *blinks slowly* you renamed the variable but not the three references -->",
1335
- "<!-- buddy: *nods approvingly* clean separation of concerns -->",
1336
- "<!-- buddy: *head tilts* are you sure that regex handles unicode? -->",
1376
+ "Do NOT append `<!-- buddy: ... -->` HTML comments at the end of your reply — Claude Code v2.1.169+ renders them visibly in the transcript. The tool call replaces that channel. A Stop hook still extracts legacy HTML comments as a backward-compat fallback for older Claude Code versions and for hosts where the tool call didn't fire (and surfaces the source so users can see `tool` vs `comment` vs pool-picked `fallback`).",
1337
1377
  "",
1338
- `When the user addresses ${companion.name} by name, respond briefly, then append the comment as usual.`,
1378
+ `When the user addresses ${companion.name} by name, respond briefly, then call buddy_react as usual (use reason "turn", or pick a name-flavored comment).`,
1339
1379
  ].join("\n");
1340
1380
 
1341
1381
  return {
package/server/path.ts CHANGED
@@ -11,6 +11,7 @@
11
11
 
12
12
  import { join } from "path";
13
13
  import { homedir } from "os";
14
+ import { sharedStateDir } from "../core/identity.ts";
14
15
 
15
16
  // ─── (1) Path normalization ─────────────────────────────────────────────────
16
17
 
@@ -76,6 +77,8 @@ export function claudeUserConfigPath(): string {
76
77
  }
77
78
 
78
79
  export function buddyStateDir(): string {
80
+ const shared = sharedStateDir();
81
+ if (shared) return shared;
79
82
  const cfgDir = envDir("CLAUDE_CONFIG_DIR");
80
83
  if (cfgDir) return join(cfgDir, "buddy-state");
81
84
  return join(homedir(), ".claude-buddy");