@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 +21 -3
- package/package.json +1 -1
- package/server/hooks/buddy-comment.ts +89 -10
- package/server/state.ts +5 -0
- package/statusline/buddy-status.sh +31 -3
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) =>
|
|
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) =>
|
|
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) =>
|
|
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
|
#!/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
|
-
|
|
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
|
-
|
|
135
|
-
|
|
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
|
-
|
|
140
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|