@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 +21 -3
- package/package.json +1 -1
- package/server/hooks/buddy-comment.ts +89 -10
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 ──────────
|