@ramarivera/coding-buddy 0.7.0 → 0.7.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.
@@ -5,14 +5,14 @@
5
5
  },
6
6
  "metadata": {
7
7
  "description": "Persistent coding companion for Claude Code, Pi, and Oh My Pi (requires bun: https://bun.sh/install)",
8
- "version": "0.7.0"
8
+ "version": "0.7.2"
9
9
  },
10
10
  "plugins": [
11
11
  {
12
12
  "name": "coding-buddy",
13
13
  "source": "./",
14
14
  "description": "Persistent coding companion for Claude Code, Pi, and Oh My Pi. MCP-based terminal pet with ASCII art, stats, reactions, and personality. Requires bun runtime (https://bun.sh/install).",
15
- "version": "0.7.0",
15
+ "version": "0.7.2",
16
16
  "author": {
17
17
  "name": "ramarivera"
18
18
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coding-buddy",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
4
4
  "description": "Persistent coding companion for Claude Code, Pi, and Oh My Pi. MCP-based terminal pet with ASCII art, stats, reactions, and personality.",
5
5
  "author": {
6
6
  "name": "ramarivera"
package/README.md CHANGED
@@ -103,7 +103,7 @@ The `claude-buddy` package ended at **v0.5.2**.
103
103
  npx -y @ramarivera/coding-buddy
104
104
  ```
105
105
 
106
- This installs the MCP server, skill, hooks, and status line. Restart Claude Code and type `/buddy`.
106
+ This installs the MCP server, skill, hooks, and status line. Claude Code hooks are registered as thin bash wrappers with 15s timeouts; the wrappers locate Bun in bare hook environments and delegate behavior to TypeScript or the phase-2 legacy bash bridge. Restart Claude Code and type `/buddy`.
107
107
 
108
108
  ### Pi
109
109
 
@@ -255,7 +255,8 @@ MCP is an industry-standard protocol. Skills are Markdown files. Hooks and statu
255
255
  coding-buddy/
256
256
  ├── server/ # MCP server — tools, engine, art, reactions, state
257
257
  ├── skills/buddy/ # /buddy slash command
258
- ├── hooks/ # PostToolUse + Stop hooks (error & comment detection)
258
+ ├── hooks/ # Thin Claude Code hook wrappers and phase-2 legacy bash hooks
259
+ ├── server/hooks/ # Hook behavior implemented in TypeScript
259
260
  ├── statusline/ # Animated right-aligned buddy display
260
261
  └── cli/ # install, show, hunt, verify, doctor, backup, uninstall
261
262
  ```
@@ -2,47 +2,143 @@ import { RARITY_STARS } from "../../core/engine.ts";
2
2
  import { getArtFrame, HAT_ART } from "../../core/render-model.ts";
3
3
  import type { Companion, ReactionState } from "../../core/model.ts";
4
4
  import type { Achievement } from "../../core/achievements.ts";
5
+ import { getRarityColor } from "../../server/theme.ts";
6
+
7
+ export const OMP_WIDGET_MAX_LINES = 10;
8
+
9
+ const ANSI_PATTERN = /\x1b\[[0-?]*[ -/]*[@-~]/g;
10
+ const RESET = "\x1b[0m";
11
+ const DIM_ITALIC = "\x1b[2;3m";
12
+ const ART_GAP = " ";
13
+ const DEFAULT_WIDGET_WIDTH = 78;
14
+
15
+ function stripAnsi(text: string): string {
16
+ return text.replace(ANSI_PATTERN, "");
17
+ }
18
+
19
+ function characterWidth(character: string, next?: string): number {
20
+ if (character === "\u200d" || /[\uFE00-\uFE0F]/u.test(character)) return 0;
21
+ if (next === "\uFE0F" && /\p{Emoji}/u.test(character)) return 2;
22
+ return /\p{Emoji_Presentation}/u.test(character) ? 2 : 1;
23
+ }
24
+
25
+ export function displayWidth(text: string): number {
26
+ const characters = [...stripAnsi(text)];
27
+ let width = 0;
28
+ for (let index = 0; index < characters.length; index += 1) {
29
+ width += characterWidth(characters[index]!, characters[index + 1]);
30
+ }
31
+ return width;
32
+ }
5
33
 
6
34
  function trimArt(line: string): string {
7
35
  return line.replace(/\s+$/g, "");
8
36
  }
9
37
 
10
- export function renderBuddyStatus(companion: Companion, reaction?: ReactionState | null): string {
11
- const shiny = companion.bones.shiny ? " ✨" : "";
12
- const stars = RARITY_STARS[companion.bones.rarity];
13
- const suffix = reaction?.reaction ? ` — ${reaction.reaction}` : "";
14
- return `${trimArt(getArtFrame(companion.bones.species, companion.bones.eye, Date.now())[0])} ${companion.name} ${stars}${shiny}${suffix}`;
38
+ function truncateToWidth(text: string, width: number): string {
39
+ if (width <= 0) return "";
40
+ let result = "";
41
+ let currentWidth = 0;
42
+ for (const character of [...stripAnsi(text)]) {
43
+ const nextWidth = characterWidth(character);
44
+ if (currentWidth + nextWidth > width) break;
45
+ result += character;
46
+ currentWidth += nextWidth;
47
+ }
48
+ return result;
15
49
  }
16
50
 
17
- export function renderBuddyWidget(
51
+ function wrapReaction(reaction: string, width: number, maxLines: number): string[] {
52
+ const lines: string[] = [];
53
+ let current = "";
54
+
55
+ for (const word of `💬 ${reaction.trim()}`.split(/\s+/)) {
56
+ if (!word) continue;
57
+ if (displayWidth(word) > width) {
58
+ if (current) lines.push(current);
59
+ lines.push(`${truncateToWidth(word, Math.max(1, width - 1))}…`);
60
+ current = "";
61
+ continue;
62
+ }
63
+
64
+ const candidate = current ? `${current} ${word}` : word;
65
+ if (displayWidth(candidate) > width) {
66
+ if (current) lines.push(current);
67
+ current = word;
68
+ } else {
69
+ current = candidate;
70
+ }
71
+ }
72
+
73
+ if (current) lines.push(current);
74
+ if (lines.length <= maxLines) return lines;
75
+
76
+ const bounded = lines.slice(0, maxLines);
77
+ const last = bounded[maxLines - 1] ?? "";
78
+ bounded[maxLines - 1] = `${truncateToWidth(last, Math.max(1, width - 1))}…`;
79
+ return bounded;
80
+ }
81
+
82
+ function getWidgetWidth(): number {
83
+ const columns = process.stdout.columns;
84
+ return columns && columns > 0 ? Math.max(24, columns - 2) : DEFAULT_WIDGET_WIDTH;
85
+ }
86
+
87
+ function getFullArtFrame(companion: Companion, frame: number): string[] {
88
+ const art = getArtFrame(companion.bones.species, companion.bones.eye, frame);
89
+ if (companion.bones.hat !== "none" && !stripAnsi(art[0] ?? "").trim()) {
90
+ art[0] = HAT_ART[companion.bones.hat];
91
+ }
92
+ return art.map(trimArt);
93
+ }
94
+
95
+ function renderDetails(
18
96
  companion: Companion,
19
- reaction?: ReactionState | null,
20
- achievements: Achievement[] = [],
97
+ reaction: ReactionState | null | undefined,
98
+ achievements: Achievement[],
99
+ sideWidth: number,
21
100
  ): string[] {
22
- const frame = Math.floor(Date.now() / 700);
23
- const art = getArtFrame(companion.bones.species, companion.bones.eye, frame).map(trimArt);
24
- const hat = companion.bones.hat === "none" ? [] : [trimArt(HAT_ART[companion.bones.hat])];
101
+ const color = getRarityColor(companion.bones.rarity);
25
102
  const stars = RARITY_STARS[companion.bones.rarity];
26
103
  const shiny = companion.bones.shiny ? " ✨" : "";
27
104
  const lines = [
28
- `buddy · ${companion.name}`,
29
- `${companion.bones.rarity} ${companion.bones.species} ${stars}${shiny}`,
30
- "",
31
- ...hat,
32
- ...art,
105
+ `${color}${companion.name} ${stars}${shiny}${RESET}`,
106
+ `${companion.bones.rarity} ${companion.bones.species}`,
33
107
  ];
34
108
 
35
109
  if (reaction?.reaction) {
36
- lines.push("", `“${reaction.reaction}”`);
110
+ const remainingLines = Math.max(1, OMP_WIDGET_MAX_LINES - lines.length);
111
+ lines.push(...wrapReaction(reaction.reaction, sideWidth, remainingLines).map((line) => `${DIM_ITALIC}${line}${RESET}`));
37
112
  }
38
113
 
39
- if (achievements.length > 0) {
40
- lines.push("", ...achievements.map((achievement) => `🏆 ${achievement.name}`));
114
+ for (const achievement of achievements) {
115
+ if (lines.length >= OMP_WIDGET_MAX_LINES) break;
116
+ lines.push(`🏆 ${achievement.name}`);
41
117
  }
42
118
 
43
119
  return lines;
44
120
  }
45
121
 
122
+ export function renderBuddyWidget(
123
+ companion: Companion,
124
+ reaction?: ReactionState | null,
125
+ achievements: Achievement[] = [],
126
+ width: number = getWidgetWidth(),
127
+ ): string[] {
128
+ const art = getFullArtFrame(companion, Math.floor(Date.now() / 700));
129
+ const artWidth = Math.max(...art.map(displayWidth), 0);
130
+ const widgetWidth = Math.max(24, width);
131
+ const sideWidth = Math.max(8, widgetWidth - artWidth - ART_GAP.length);
132
+ const details = renderDetails(companion, reaction, achievements, sideWidth);
133
+ const rows = Math.max(art.length, details.length);
134
+
135
+ return Array.from({ length: Math.min(OMP_WIDGET_MAX_LINES, rows) }, (_, index) => {
136
+ const artLine = art[index] ?? "";
137
+ const detailLine = details[index] ?? "";
138
+ return `${detailLine}${" ".repeat(Math.max(0, sideWidth - displayWidth(detailLine)))}${ART_GAP}${artLine}${" ".repeat(Math.max(0, artWidth - displayWidth(artLine)))}`;
139
+ });
140
+ }
141
+
46
142
  export function renderBuddyStats(companion: Companion): string[] {
47
143
  return [
48
144
  `name: ${companion.name}`,
@@ -1,7 +1,7 @@
1
1
  import type { OmpBuddyUiContext } from "./context.ts";
2
2
  import type { Achievement } from "../../core/achievements.ts";
3
3
  import type { Companion, ReactionState } from "../../core/model.ts";
4
- import { renderAchievementsSummary, renderBuddyStatus, renderBuddyWidget } from "./renderers.ts";
4
+ import { renderAchievementsSummary, renderBuddyWidget } from "./renderers.ts";
5
5
  import { OmpBuddyStorage } from "./storage.ts";
6
6
 
7
7
  export class OmpBuddyUI {
@@ -27,8 +27,7 @@ export class OmpBuddyUI {
27
27
  ): void {
28
28
  const currentReaction = reaction ?? null;
29
29
  const muted = this.storage.isMuted();
30
- const status = renderBuddyStatus(companion, muted ? null : currentReaction);
31
- ctx.ui.setStatus("buddy", muted ? `${status} [muted]` : status);
30
+ ctx.ui.setStatus("buddy", undefined);
32
31
  ctx.ui.setWidget(
33
32
  "buddy",
34
33
  renderBuddyWidget(companion, muted ? null : currentReaction, achievements),
package/cli/install.ts CHANGED
@@ -8,12 +8,13 @@
8
8
  */
9
9
 
10
10
  import { readFileSync, writeFileSync, mkdirSync, existsSync, cpSync } from "fs";
11
- import { execSync } from "child_process";
11
+ import { execFileSync, execSync } from "child_process";
12
12
  import { resolve, dirname, join } from "path";
13
13
 
14
14
  import { generateBones, renderBuddy, renderFace, RARITY_STARS } from "../core/engine.ts"
15
15
  import {
16
16
  claudeConfigDir,
17
+ buddyStateDir,
17
18
  claudeSettingsPath,
18
19
  claudeSkillDir,
19
20
  claudeUserConfigPath,
@@ -21,6 +22,7 @@ import {
21
22
  } from "../server/path.ts";
22
23
  import { loadCompanion, saveCompanion, resolveUserId, writeStatusState } from "../server/state.ts";
23
24
  import { generateFallbackName } from "../core/reactions.ts"
25
+ import { copyRuntimeApp, stableRuntimePaths } from "./runtime-app.ts";
24
26
 
25
27
  const CYAN = "\x1b[36m";
26
28
  const GREEN = "\x1b[32m";
@@ -35,6 +37,7 @@ const SETTINGS_FILE = claudeSettingsPath();
35
37
  const BUDDY_DIR = claudeSkillDir("buddy");
36
38
  const CLAUDE_JSON_PATH = claudeUserConfigPath();
37
39
  const PROJECT_ROOT = resolve(dirname(import.meta.dir));
40
+ const HOOK_TIMEOUT_SECONDS = 15;
38
41
 
39
42
  function banner() {
40
43
  console.log(`
@@ -115,8 +118,8 @@ function saveSettings(settings: Record<string, any>) {
115
118
 
116
119
  // ─── Step 1: Register MCP server (in ~/.claude.json) ────────────────────────
117
120
 
118
- function installMcp() {
119
- const serverPath = join(PROJECT_ROOT, "server", "index.ts");
121
+ function installMcp(appDir: string) {
122
+ const serverPath = stableRuntimePaths(appDir).mcpServer;
120
123
 
121
124
  let claudeJson: Record<string, any> = {};
122
125
  try {
@@ -128,7 +131,7 @@ function installMcp() {
128
131
  claudeJson.mcpServers["claude-buddy"] = {
129
132
  command: "bun",
130
133
  args: [toUnixPath(serverPath)],
131
- cwd: toUnixPath(PROJECT_ROOT),
134
+ cwd: toUnixPath(appDir),
132
135
  };
133
136
 
134
137
  writeFileSync(CLAUDE_JSON_PATH, JSON.stringify(claudeJson, null, 2));
@@ -146,8 +149,8 @@ function installSkill() {
146
149
 
147
150
  // ─── Step 3: Configure status line (with animation refresh) ─────────────────
148
151
 
149
- function installStatusLine(settings: Record<string, any>) {
150
- const statusScript = join(PROJECT_ROOT, "statusline", "buddy-status.sh");
152
+ function installStatusLine(settings: Record<string, any>, appDir: string) {
153
+ const statusScript = stableRuntimePaths(appDir).statusline;
151
154
 
152
155
  settings.statusLine = {
153
156
  type: "command",
@@ -180,13 +183,19 @@ function stripLegacyPopupHooks(settings: Record<string, any>) {
180
183
 
181
184
  // ─── Step 4: Register hooks ─────────────────────────────────────────────────
182
185
 
183
- function installHooks(settings: Record<string, any>) {
184
- const reactHook = join(PROJECT_ROOT, "hooks", "react.sh");
185
- const fileTypeHook = join(PROJECT_ROOT, "hooks", "file-type-react.sh");
186
- const commentHook = join(PROJECT_ROOT, "hooks", "buddy-comment.sh");
187
- const suggestHook = join(PROJECT_ROOT, "hooks", "suggest.sh");
188
- const nameHook = join(PROJECT_ROOT, "hooks", "name-react.sh");
189
- const moodHook = join(PROJECT_ROOT, "hooks", "mood-react.sh");
186
+ function installHooks(settings: Record<string, any>, appDir: string) {
187
+ const hooksDir = stableRuntimePaths(appDir).hooks;
188
+ const reactHook = join(hooksDir, "react.sh");
189
+ const fileTypeHook = join(hooksDir, "file-type-react.sh");
190
+ const commentHook = join(hooksDir, "buddy-comment.sh");
191
+ const suggestHook = join(hooksDir, "suggest.sh");
192
+ const nameHook = join(hooksDir, "name-react.sh");
193
+ const moodHook = join(hooksDir, "mood-react.sh");
194
+ const commandHook = (command: string) => ({
195
+ type: "command",
196
+ command: toUnixPath(command),
197
+ timeout: HOOK_TIMEOUT_SECONDS,
198
+ });
190
199
 
191
200
  if (!settings.hooks) settings.hooks = {};
192
201
 
@@ -198,11 +207,11 @@ function installHooks(settings: Record<string, any>) {
198
207
  );
199
208
  settings.hooks.PostToolUse.push({
200
209
  matcher: "Bash",
201
- hooks: [{ type: "command", command: toUnixPath(reactHook) }],
210
+ hooks: [commandHook(reactHook)],
202
211
  });
203
212
  settings.hooks.PostToolUse.push({
204
213
  matcher: "Write|Edit",
205
- hooks: [{ type: "command", command: toUnixPath(fileTypeHook) }],
214
+ hooks: [commandHook(fileTypeHook)],
206
215
  });
207
216
 
208
217
  // Stop: extract <!-- buddy: --> comment from Claude's response
@@ -211,10 +220,10 @@ function installHooks(settings: Record<string, any>) {
211
220
  (h: any) => !h.hooks?.some((hh: any) => hh.command?.includes("coding-buddy") || hh.command?.includes("claude-buddy")),
212
221
  );
213
222
  settings.hooks.Stop.push({
214
- hooks: [{ type: "command", command: toUnixPath(commentHook) }],
223
+ hooks: [commandHook(commentHook)],
215
224
  });
216
225
  settings.hooks.Stop.push({
217
- hooks: [{ type: "command", command: toUnixPath(suggestHook) }],
226
+ hooks: [commandHook(suggestHook)],
218
227
  });
219
228
 
220
229
  // UserPromptSubmit: detect buddy's name in user message → instant status line
@@ -224,15 +233,24 @@ function installHooks(settings: Record<string, any>) {
224
233
  (h: any) => !h.hooks?.some((hh: any) => hh.command?.includes("coding-buddy") || hh.command?.includes("claude-buddy")),
225
234
  );
226
235
  settings.hooks.UserPromptSubmit.push({
227
- hooks: [{ type: "command", command: toUnixPath(nameHook) }],
236
+ hooks: [commandHook(nameHook)],
228
237
  });
229
238
  settings.hooks.UserPromptSubmit.push({
230
- hooks: [{ type: "command", command: toUnixPath(moodHook) }],
239
+ hooks: [commandHook(moodHook)],
231
240
  });
232
241
 
233
242
  ok("Hooks registered: PostToolUse (Bash + Write/Edit) + Stop (comment + suggest) + UserPromptSubmit (name + mood)");
234
243
  }
235
244
 
245
+ function installRuntimeApp(): string {
246
+ const appDir = copyRuntimeApp(PROJECT_ROOT, buddyStateDir());
247
+ const installArgs = ["install", "--production", "--ignore-scripts"];
248
+ if (existsSync(join(appDir, "bun.lock"))) installArgs.push("--frozen-lockfile");
249
+ execFileSync("bun", installArgs, { cwd: appDir, stdio: "ignore" });
250
+ ok(`Stable runtime installed: ${appDir}`);
251
+ return appDir;
252
+ }
253
+
236
254
  // ─── Step 5: Ensure MCP tools are allowed ───────────────────────────────────
237
255
 
238
256
  function ensurePermissions(settings: Record<string, any>) {
@@ -295,14 +313,15 @@ console.log("");
295
313
  info("Installing coding-buddy...\n");
296
314
 
297
315
  const settings = loadSettings();
316
+ const appDir = installRuntimeApp();
298
317
 
299
- installMcp();
318
+ installMcp(appDir);
300
319
  installSkill();
301
320
 
302
321
  stripLegacyPopupHooks(settings);
303
- installStatusLine(settings);
322
+ installStatusLine(settings, appDir);
304
323
 
305
- installHooks(settings);
324
+ installHooks(settings, appDir);
306
325
  ensurePermissions(settings);
307
326
  saveSettings(settings);
308
327
 
@@ -0,0 +1,49 @@
1
+ import { cpSync, existsSync, mkdirSync, rmSync } from "fs";
2
+ import { join } from "path";
3
+
4
+ export const RUNTIME_DIRECTORIES = [
5
+ "core",
6
+ "server",
7
+ "hooks",
8
+ "statusline",
9
+ "scripts",
10
+ ] as const;
11
+
12
+ export const RUNTIME_FILES = ["package.json", "bun.lock"] as const;
13
+
14
+ export function stableRuntimeAppDir(stateDir: string): string {
15
+ return join(stateDir, "app");
16
+ }
17
+
18
+ export function stableRuntimePaths(appDir: string) {
19
+ return {
20
+ mcpServer: join(appDir, "server", "index.ts"),
21
+ mcpLauncher: join(appDir, "server", "mcp-launcher.sh"),
22
+ statusline: join(appDir, "statusline", "buddy-status.sh"),
23
+ combinedStatusline: join(appDir, "statusline", "combined-status.sh"),
24
+ hooks: join(appDir, "hooks"),
25
+ };
26
+ }
27
+
28
+ /**
29
+ * Replace the stable runtime copy. The app directory is owned by the
30
+ * installer, so removing it first also removes files deleted by an upgrade.
31
+ */
32
+ export function copyRuntimeApp(sourceRoot: string, stateDir: string): string {
33
+ const appDir = stableRuntimeAppDir(stateDir);
34
+ rmSync(appDir, { recursive: true, force: true });
35
+ mkdirSync(appDir, { recursive: true });
36
+
37
+ for (const directory of RUNTIME_DIRECTORIES) {
38
+ const source = join(sourceRoot, directory);
39
+ if (!existsSync(source)) throw new Error(`Missing runtime directory: ${source}`);
40
+ cpSync(source, join(appDir, directory), { recursive: true, force: true });
41
+ }
42
+
43
+ for (const file of RUNTIME_FILES) {
44
+ const source = join(sourceRoot, file);
45
+ if (existsSync(source)) cpSync(source, join(appDir, file), { force: true });
46
+ }
47
+
48
+ return appDir;
49
+ }
@@ -1,81 +1,11 @@
1
1
  #!/usr/bin/env bash
2
- # buddy-comment Stop hook
3
- # Extracts hidden buddy comment from Claude's response.
4
- # Claude writes: <!-- buddy: *adjusts tophat* nice code -->
5
- # This hook extracts it and updates the status line bubble.
6
- # The HTML comment is invisible in rendered markdown output.
7
-
8
- # shellcheck source=../scripts/paths.sh
9
- source "$(dirname "${BASH_SOURCE[0]}")/../scripts/paths.sh"
10
-
11
- STATE_DIR="$BUDDY_STATE_DIR"
12
- # Per-session ID resolved by paths.sh (CLAUDE_CODE_SESSION_ID > TMUX_PANE > default)
13
- SID="$BUDDY_SID"
14
- STATUS_FILE="$STATE_DIR/status.json"
15
- COOLDOWN_FILE="$STATE_DIR/.last_comment.$SID"
16
- CONFIG_FILE="$STATE_DIR/config.json"
17
- EVENTS_FILE="$STATE_DIR/events.json"
18
-
19
- [ -f "$STATUS_FILE" ] || exit 0
20
-
21
- # Read cooldown from config (default 30s, 0 = disabled)
22
- COOLDOWN=30
23
- if [ -f "$CONFIG_FILE" ]; then
24
- _cd=$(jq -r '.commentCooldown // 30' "$CONFIG_FILE" 2>/dev/null || echo 30)
25
- # Accept any non-negative integer (including 0 to disable cooldown)
26
- [[ "$_cd" =~ ^[0-9]+$ ]] && COOLDOWN=$_cd
27
- fi
28
-
29
- INPUT=$(cat)
30
-
31
- # Extract last_assistant_message from hook input
32
- MSG=$(echo "$INPUT" | jq -r '.last_assistant_message // ""' 2>/dev/null)
33
- [ -z "$MSG" ] && exit 0
34
-
35
- # Extract <!-- buddy: ... --> comment (portable, no grep -P)
36
- COMMENT=$(echo "$MSG" | sed -n 's/.*<!-- *buddy: *\(.*[^ ]\) *-->.*/\1/p' | tail -1)
37
- [ -z "$COMMENT" ] && exit 0
38
-
39
- # Cooldown: configurable (default 30s)
40
- if [ -f "$COOLDOWN_FILE" ]; then
41
- LAST=$(cat "$COOLDOWN_FILE" 2>/dev/null)
42
- NOW=$(date +%s)
43
- [ $(( NOW - ${LAST:-0} )) -lt "$COOLDOWN" ] && exit 0
44
- fi
45
-
46
- mkdir -p "$STATE_DIR"
47
- date +%s > "$COOLDOWN_FILE"
48
-
49
- # Write reaction to per-session file (read by the statusline via $SID)
50
- jq -n --arg r "$COMMENT" --arg ts "$(date +%s)000" \
51
- '{reaction: $r, timestamp: ($ts | tonumber), reason: "turn"}' \
52
- > "$STATE_DIR/reaction.$SID.json"
53
-
54
- # Increment achievement event counters and award XP
55
- if command -v jq >/dev/null 2>&1; then
56
- if [ ! -f "$EVENTS_FILE" ]; then
57
- echo '{}' > "$EVENTS_FILE"
58
- fi
59
- TMP=$(mktemp)
60
- jq '.turns = (.turns // 0 + 1)' "$EVENTS_FILE" > "$TMP" 2>/dev/null && mv "$TMP" "$EVENTS_FILE"
61
- fi
62
-
63
- # Award XP for turn (async, non-blocking)
64
- if [ -x "$(command -v bun)" ]; then
65
- PLUGIN_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
66
- bun run "$PLUGIN_ROOT/server/award-xp.ts" "turn" >/dev/null 2>&1 &
67
- fi
68
-
69
- # Consolidate memory (async, non-blocking)
70
- # Extract project, bug, and preference signals from conversation
71
- if [ -x "$(command -v bun)" ]; then
72
- PLUGIN_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
73
- # Pass the assistant message for analysis (pass empty string for user prompt if unavailable)
74
- USER_MSG=$(echo "$INPUT" | jq -r '.last_user_message // ""' 2>/dev/null)
75
- bun run "$PLUGIN_ROOT/server/consolidate.ts" \
76
- "$(echo "$MSG" | jq -Rs .)" \
77
- "$(echo "$USER_MSG" | jq -Rs .)" \
78
- >/dev/null 2>&1 &
79
- fi
80
-
81
- exit 0
2
+ set +e
3
+ SCRIPT_DIR="${BASH_SOURCE[0]%/*}"
4
+ ROOT="$(cd "$SCRIPT_DIR/.." 2>/dev/null && pwd)" || exit 0
5
+ [ -n "$ROOT" ] || exit 0
6
+ BUN="$(command -v bun 2>/dev/null || true)"
7
+ [ -x "$BUN" ] || BUN="$HOME/.bun/bin/bun"
8
+ [ -x "$BUN" ] || BUN="/opt/homebrew/bin/bun"
9
+ [ -x "$BUN" ] || BUN="/usr/local/bin/bun"
10
+ [ -x "$BUN" ] || exit 0
11
+ exec "$BUN" run "$ROOT/server/hooks/buddy-comment.ts" || exit 0