@ramarivera/coding-buddy 0.9.1 → 0.9.3

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.
package/cli/install.ts CHANGED
@@ -24,6 +24,17 @@ import { loadCompanion, saveCompanion, resolveUserId, writeStatusState } from ".
24
24
  import { generateFallbackName } from "../core/reactions.ts"
25
25
  import { copyRuntimeApp, stableRuntimePaths } from "./runtime-app.ts";
26
26
 
27
+ /** Recognise any buddy-owned hook command, however it is pathed. */
28
+ function isBuddyHookCommand(command: unknown): boolean {
29
+ if (typeof command !== "string") return false;
30
+ return (
31
+ command.includes("coding-buddy") ||
32
+ command.includes("claude-buddy") ||
33
+ command.includes("buddy-state") ||
34
+ /\/(buddy-comment|suggest|react|mood-react|name-react|file-type-react)\.(sh|ts)\b/.test(command)
35
+ );
36
+ }
37
+
27
38
  const CYAN = "\x1b[36m";
28
39
  const GREEN = "\x1b[32m";
29
40
  const YELLOW = "\x1b[33m";
@@ -203,7 +214,7 @@ function installHooks(settings: Record<string, any>, appDir: string) {
203
214
  // plus file-type specific reactions on Write/Edit.
204
215
  if (!settings.hooks.PostToolUse) settings.hooks.PostToolUse = [];
205
216
  settings.hooks.PostToolUse = settings.hooks.PostToolUse.filter(
206
- (h: any) => !h.hooks?.some((hh: any) => hh.command?.includes("coding-buddy") || hh.command?.includes("claude-buddy")),
217
+ (h: any) => !h.hooks?.some((hh: any) => isBuddyHookCommand(hh.command)),
207
218
  );
208
219
  settings.hooks.PostToolUse.push({
209
220
  matcher: "Bash",
@@ -216,8 +227,15 @@ function installHooks(settings: Record<string, any>, appDir: string) {
216
227
 
217
228
  // Stop: extract <!-- buddy: --> comment from Claude's response
218
229
  if (!settings.hooks.Stop) settings.hooks.Stop = [];
230
+ // Match on the hook script filenames, not the package name: the installed
231
+ // path is <state>/app/hooks/buddy-comment.sh, which contains neither
232
+ // "coding-buddy" nor "claude-buddy". The old filter therefore never matched
233
+ // and every install appended another pair — observed at 8 Stop entries on a
234
+ // dogfooding machine. Duplicates are not harmless: the first invocation
235
+ // adopts a buddy_react reaction and stamps the stop marker, then the rest
236
+ // see it as stale and overwrite the bubble with a canned pool line.
219
237
  settings.hooks.Stop = settings.hooks.Stop.filter(
220
- (h: any) => !h.hooks?.some((hh: any) => hh.command?.includes("coding-buddy") || hh.command?.includes("claude-buddy")),
238
+ (h: any) => !h.hooks?.some((hh: any) => isBuddyHookCommand(hh.command)),
221
239
  );
222
240
  settings.hooks.Stop.push({
223
241
  hooks: [commandHook(commentHook)],
@@ -230,7 +248,7 @@ function installHooks(settings: Record<string, any>, appDir: string) {
230
248
  // reaction, plus mood-react based on prompt content.
231
249
  if (!settings.hooks.UserPromptSubmit) settings.hooks.UserPromptSubmit = [];
232
250
  settings.hooks.UserPromptSubmit = settings.hooks.UserPromptSubmit.filter(
233
- (h: any) => !h.hooks?.some((hh: any) => hh.command?.includes("coding-buddy") || hh.command?.includes("claude-buddy")),
251
+ (h: any) => !h.hooks?.some((hh: any) => isBuddyHookCommand(hh.command)),
234
252
  );
235
253
  settings.hooks.UserPromptSubmit.push({
236
254
  hooks: [commandHook(nameHook)],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ramarivera/coding-buddy",
3
- "version": "0.9.1",
3
+ "version": "0.9.3",
4
4
  "description": "Persistent coding companion for Claude Code, Pi, and Oh My Pi",
5
5
  "type": "module",
6
6
  "omp": {
@@ -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
@@ -454,6 +454,10 @@ export interface StatusState {
454
454
  reaction: string;
455
455
  muted: boolean;
456
456
  achievement: string;
457
+ /** Epoch ms the achievement was awarded; 0 when none. The statusline expires
458
+ * the trophy banner on this, otherwise it latches into the shared
459
+ * status.json and pins every session's bubble to the last unlock. */
460
+ achievementAt: number;
457
461
  frames: string[];
458
462
  compactFrames: string[];
459
463
  minimalFrames: string[];
@@ -543,6 +547,7 @@ export function writeStatusState(
543
547
  reaction: "",
544
548
  muted: muted ?? false,
545
549
  achievement: achievement ?? "",
550
+ achievementAt: achievement ? Date.now() : 0,
546
551
  frames,
547
552
  compactFrames,
548
553
  minimalFrames,
@@ -58,6 +58,9 @@ STARS=$(jq -r '.stars // ""' "$STATE" 2>/dev/null)
58
58
  SHINY=$(jq -r '.shiny // false' "$STATE" 2>/dev/null)
59
59
  REACTION_FILE="$BUDDY_STATE_DIR/reaction.$SID.json"
60
60
  ACHIEVEMENT=$(jq -r '.achievement // ""' "$STATE" 2>/dev/null)
61
+ # "absent" distinguishes a legacy status.json (no field at all) from an explicit
62
+ # 0, which means "no achievement pending" and must not render.
63
+ ACHIEVEMENT_AT=$(jq -r 'if has("achievementAt") then (.achievementAt // 0) else "absent" end' "$STATE" 2>/dev/null)
61
64
  LEVEL=$(jq -r '.level // 1' "$STATE" 2>/dev/null)
62
65
  MOOD=$(jq -r '.mood // "focused"' "$STATE" 2>/dev/null)
63
66
 
@@ -229,10 +232,10 @@ DETECTED_COLS="$COLS"
229
232
  DETECTED_ROWS="$ROWS"
230
233
 
231
234
  # ─── Reaction bubble (with TTL check) ────────────────────────────────────────
235
+ # The achievement banner is resolved further down, once REACTION_TTL is known —
236
+ # it expires on the same clock as a reaction. Without that it latches into the
237
+ # shared status.json and pins every session's bubble to the last trophy.
232
238
  BUBBLE=""
233
- if [ -n "$ACHIEVEMENT" ] && [ "$ACHIEVEMENT" != "null" ] && [ "$ACHIEVEMENT" != "" ]; then
234
- BUBBLE=$'\xf0\x9f\x8f\x86'" $ACHIEVEMENT"
235
- fi
236
239
  REACTION_TTL=900
237
240
  INNER_W=44
238
241
  MARGIN=8
@@ -307,6 +310,31 @@ _sweep_expired_reactions() {
307
310
  }
308
311
 
309
312
  _sweep_expired_reactions
313
+
314
+ # Achievement banner: shown only while fresh. ACHIEVEMENT_AT is epoch ms, written
315
+ # alongside the name by writeStatusState. A legacy status.json without the field
316
+ # (pre-upgrade, or a snapshot fixture) is treated as fresh — the next write from
317
+ # the server backfills it.
318
+ #
319
+ # Validity and age are separate gates on purpose. A zeroed or malformed
320
+ # achievementAt means "nothing pending" and must never render, including under
321
+ # reactionTTL=0 — that opt-out disables *expiry*, not the field's meaning.
322
+ if [ -n "$ACHIEVEMENT" ] && [ "$ACHIEVEMENT" != "null" ]; then
323
+ ACH_FRESH=1
324
+ if [ "$ACHIEVEMENT_AT" != "absent" ]; then
325
+ case "$ACHIEVEMENT_AT" in
326
+ ''|0|*[!0-9]*) ACH_FRESH=0 ;;
327
+ *)
328
+ if [ "$REACTION_TTL" -gt 0 ] 2>/dev/null; then
329
+ ACH_AGE=$(( ($(date +%s) * 1000 - ACHIEVEMENT_AT) / 1000 ))
330
+ [ "$ACH_AGE" -ge "$REACTION_TTL" ] && ACH_FRESH=0
331
+ fi
332
+ ;;
333
+ esac
334
+ fi
335
+ [ "$ACH_FRESH" -eq 1 ] && BUBBLE=$'\xf0\x9f\x8f\x86'" $ACHIEVEMENT"
336
+ fi
337
+
310
338
  REACTION=$(jq -r '.reaction // ""' "$REACTION_FILE" 2>/dev/null)
311
339
  if [ -n "$REACTION" ] && [ "$REACTION" != "null" ] && [ "$REACTION" != "" ]; then
312
340
  FRESH=0