@ramarivera/coding-buddy 0.9.1 → 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.
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.2",
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 ──────────