@ramarivera/coding-buddy 0.7.3 → 0.8.0

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.3"
8
+ "version": "0.8.0"
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.3",
15
+ "version": "0.8.0",
16
16
  "author": {
17
17
  "name": "ramarivera"
18
18
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coding-buddy",
3
- "version": "0.7.3",
3
+ "version": "0.8.0",
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"
@@ -8,4 +8,4 @@ BUN="$(command -v bun 2>/dev/null || true)"
8
8
  [ -x "$BUN" ] || BUN="/opt/homebrew/bin/bun"
9
9
  [ -x "$BUN" ] || BUN="/usr/local/bin/bun"
10
10
  [ -x "$BUN" ] || exit 0
11
- exec "$BUN" run "$ROOT/server/hooks/legacy-bash.ts" file-type-react.sh || exit 0
11
+ exec "$BUN" run "$ROOT/server/hooks/file-type-react.ts" || exit 0
@@ -8,4 +8,4 @@ BUN="$(command -v bun 2>/dev/null || true)"
8
8
  [ -x "$BUN" ] || BUN="/opt/homebrew/bin/bun"
9
9
  [ -x "$BUN" ] || BUN="/usr/local/bin/bun"
10
10
  [ -x "$BUN" ] || exit 0
11
- exec "$BUN" run "$ROOT/server/hooks/legacy-bash.ts" mood-react.sh || exit 0
11
+ exec "$BUN" run "$ROOT/server/hooks/mood-react.ts" || exit 0
@@ -8,4 +8,4 @@ BUN="$(command -v bun 2>/dev/null || true)"
8
8
  [ -x "$BUN" ] || BUN="/opt/homebrew/bin/bun"
9
9
  [ -x "$BUN" ] || BUN="/usr/local/bin/bun"
10
10
  [ -x "$BUN" ] || exit 0
11
- exec "$BUN" run "$ROOT/server/hooks/legacy-bash.ts" name-react.sh || exit 0
11
+ exec "$BUN" run "$ROOT/server/hooks/name-react.ts" || exit 0
package/hooks/react.sh CHANGED
@@ -8,4 +8,4 @@ BUN="$(command -v bun 2>/dev/null || true)"
8
8
  [ -x "$BUN" ] || BUN="/opt/homebrew/bin/bun"
9
9
  [ -x "$BUN" ] || BUN="/usr/local/bin/bun"
10
10
  [ -x "$BUN" ] || exit 0
11
- exec "$BUN" run "$ROOT/server/hooks/legacy-bash.ts" react.sh || exit 0
11
+ exec "$BUN" run "$ROOT/server/hooks/react.ts" || exit 0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ramarivera/coding-buddy",
3
- "version": "0.7.3",
3
+ "version": "0.8.0",
4
4
  "description": "Persistent coding companion for Claude Code, Pi, and Oh My Pi",
5
5
  "type": "module",
6
6
  "omp": {
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env bash
2
+ # CI golden check for the Claude Code statusline: renders buddy-status.sh
3
+ # against the committed fixture state and asserts the output shape.
4
+ # Run from the repo root: bash scripts/ci/check-statusline.sh
5
+ set -euo pipefail
6
+
7
+ fixture_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/statusline-fixture" && pwd)"
8
+ payload='{"session_id":"ci","model":{"display_name":"CI"},"workspace":{"current_dir":"."}}'
9
+
10
+ start=$(date +%s)
11
+ output="$(printf '%s' "$payload" | CLAUDE_CONFIG_DIR="$fixture_dir" bash statusline/buddy-status.sh)"
12
+ elapsed=$(( $(date +%s) - start ))
13
+
14
+ plain="$(printf '%s' "$output" | sed $'s/\x1b\[[0-9;]*m//g')"
15
+ lines=$(printf '%s\n' "$plain" | wc -l | tr -d ' ')
16
+
17
+ fail() { echo "FAIL: $1" >&2; printf '%s\n' "$plain" >&2; exit 1; }
18
+
19
+ name="$(jq -r .name "$fixture_dir/buddy-state/status.json")"
20
+ printf '%s' "$plain" | grep -q "$name" || fail "pet name '$name' missing from output"
21
+ [ "$lines" -ge 4 ] || fail "expected >=4 output lines, got $lines"
22
+ [ "$elapsed" -le 10 ] || fail "statusline took ${elapsed}s (budget 10s)"
23
+
24
+ echo "OK: statusline rendered $lines lines with '$name' in ${elapsed}s"
@@ -0,0 +1 @@
1
+ {"useCombinedStatus": false}
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "Cobalt",
3
+ "species": "pikachu",
4
+ "rarity": "uncommon",
5
+ "stars": "★★",
6
+ "face": "(@ω@)",
7
+ "eye": "@",
8
+ "shiny": false,
9
+ "hat": "wizard",
10
+ "reaction": "*does a little dance*",
11
+ "muted": false,
12
+ "achievement": "",
13
+ "frames": [
14
+ " /^\\ \n /\\_/\\ \n (@ @) \n ( ω ) \n (__) ",
15
+ " /^\\ \n /\\_/\\ \n (- -) \n ( ω ) \n (__) ",
16
+ " /^\\ \n /\\_/\\ \n (@ @) \n ( ~ ) \n (__) ",
17
+ " /^\\ \n /\\_/\\ \n (- -) \n ( ω ) \n (__) "
18
+ ],
19
+ "frameSequence": [
20
+ 0,
21
+ 0,
22
+ 0,
23
+ 0,
24
+ 1,
25
+ 0,
26
+ 0,
27
+ 0,
28
+ 3,
29
+ 0,
30
+ 0,
31
+ 2,
32
+ 0,
33
+ 0,
34
+ 0
35
+ ],
36
+ "level": 1,
37
+ "xp": 0,
38
+ "mood": "tired"
39
+ }
@@ -0,0 +1,9 @@
1
+ Output scripts/ci/statusline.gif
2
+ Set Width 1400
3
+ Set Height 500
4
+ Set FontSize 14
5
+ Set Shell "bash"
6
+ Type "echo '{\"session_id\":\"ci\",\"model\":{\"display_name\":\"CI\"},\"workspace\":{\"current_dir\":\".\"}}' | CLAUDE_CONFIG_DIR=scripts/ci/statusline-fixture bash statusline/buddy-status.sh"
7
+ Enter
8
+ Sleep 12s
9
+ Screenshot scripts/ci/statusline.png
@@ -6,13 +6,23 @@ import { buddyStateDir } from "../path.ts";
6
6
  export type JsonObject = Record<string, unknown>;
7
7
 
8
8
  export interface HookRuntime {
9
+ clock?: () => HookClock;
9
10
  now?: () => number;
10
11
  packageRoot?: string;
12
+ random?: () => number;
11
13
  sessionId?: string;
12
14
  spawnDetached?: (script: string, args: string[]) => void;
13
15
  stateDir?: string;
14
16
  }
15
17
 
18
+ export interface HookClock {
19
+ day: number;
20
+ dayOfWeek: number;
21
+ hour: number;
22
+ month: number;
23
+ nowSeconds: number;
24
+ }
25
+
16
26
  export function parseHookInput(raw: string): JsonObject | null {
17
27
  try {
18
28
  const value = JSON.parse(raw) as unknown;
@@ -56,6 +66,29 @@ export function nonNegativeInteger(value: unknown, fallback: number): number {
56
66
  return fallback;
57
67
  }
58
68
 
69
+ export function randomIndex(length: number, runtime: HookRuntime = {}): number {
70
+ return Math.floor((runtime.random?.() ?? Math.random()) * length);
71
+ }
72
+
73
+ export function pickRandom<T>(values: readonly T[], runtime: HookRuntime = {}): T | undefined {
74
+ if (values.length === 0) return undefined;
75
+ return values[randomIndex(values.length, runtime)];
76
+ }
77
+
78
+ export function hookClock(runtime: HookRuntime = {}): HookClock {
79
+ if (runtime.clock) return runtime.clock();
80
+ const nowMs = runtime.now?.() ?? Date.now();
81
+ const date = new Date(nowMs);
82
+ const jsDay = date.getDay();
83
+ return {
84
+ day: date.getDate(),
85
+ dayOfWeek: jsDay === 0 ? 7 : jsDay,
86
+ hour: date.getHours(),
87
+ month: date.getMonth() + 1,
88
+ nowSeconds: Math.floor(nowMs / 1000),
89
+ };
90
+ }
91
+
59
92
  export function isOnCooldown(path: string, cooldownSeconds: number, nowMs: number): boolean {
60
93
  try {
61
94
  const last = Number.parseInt(readFileSync(path, "utf8").trim(), 10);
@@ -0,0 +1,147 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { mkdirSync, writeFileSync } from "fs";
4
+ import { join } from "path";
5
+ import { fileReactionPool } from "./reaction-data.ts";
6
+ import {
7
+ fileExists,
8
+ isOnCooldown,
9
+ nonNegativeInteger,
10
+ parseHookInput,
11
+ pickRandom,
12
+ readJsonFile,
13
+ readStdin,
14
+ resolveHookSessionId,
15
+ resolveHookStateDir,
16
+ type HookRuntime,
17
+ } from "./common.ts";
18
+
19
+ interface BuddyStatus {
20
+ muted?: unknown;
21
+ species?: unknown;
22
+ reaction?: unknown;
23
+ [key: string]: unknown;
24
+ }
25
+
26
+ interface FileConfig {
27
+ commentCooldown?: unknown;
28
+ }
29
+
30
+ export interface FileTypeReactResult {
31
+ fileType?: string;
32
+ reaction?: string;
33
+ updated: boolean;
34
+ }
35
+
36
+ const FILE_TYPE_PATTERNS: ReadonlyArray<readonly [string, string]> = [
37
+ ["readme", "*/README*|*/CHANGELOG*|*/CONTRIBUTING*|*/LICENSE*"],
38
+ ["package-file", "*/package.json|*/Cargo.toml|*/go.mod|*/go.sum|*/pyproject.toml|*/requirements*.txt"],
39
+ ["ci-file", "*/.github/workflows/*|*/Jenkinsfile*|*/.gitlab-ci*|*.ci.yml|*.ci.yaml"],
40
+ ["gitignore", "*/.gitignore|*/.dockerignore|*/.eslintignore|*/.prettierignore|.gitignore|.dockerignore|.eslintignore|.prettierignore"],
41
+ ["env-file", "*/.env*|.env*"],
42
+ ["makefile", "*/Makefile*|Makefile*|*.mk"],
43
+ ["docker-file", "*/Dockerfile*|Dockerfile*|*/docker-compose*|docker-compose*|*.dockerfile"],
44
+ ["regex-file", "*.regex|*.pattern"],
45
+ ["css-file", "*.css|*.scss|*.less|*.sass"],
46
+ ["sql-file", "*.sql|*migration*"],
47
+ ["lock-file", "*.lock|*/package-lock.json|*/bun.lockb|*/yarn.lock|*/pnpm-lock.yaml"],
48
+ ["test-file", "*.test.*|*.spec.*|*_test.*|*test_*"],
49
+ ["doc-file", "*.md|*.rst|*.txt|*.adoc"],
50
+ ["config-file", "*.json|*.yaml|*.yml|*.toml|*.ini|*.conf"],
51
+ ["binary-file", "*.png|*.jpg|*.gif|*.woff*|*.ico|*.exe|*.so|*.dylib|*.bin"],
52
+ ["proto-file", "*.proto|*.graphql|*.gql"],
53
+ ["lang-python", "*.py"],
54
+ ["lang-typescript", "*.ts|*.tsx"],
55
+ ["lang-javascript", "*.js|*.jsx"],
56
+ ["lang-rust", "*.rs"],
57
+ ["lang-go", "*.go"],
58
+ ["lang-java", "*.java"],
59
+ ["lang-ruby", "*.rb"],
60
+ ["lang-php", "*.php"],
61
+ ["lang-c", "*.c|*.h"],
62
+ ["lang-cpp", "*.cpp|*.hpp"],
63
+ ["lang-swift", "*.swift"],
64
+ ["lang-kotlin", "*.kt"],
65
+ ["lang-haskell", "*.hs"],
66
+ ["lang-elixir", "*.ex|*.exs"],
67
+ ["lang-zig", "*.zig"],
68
+ ];
69
+
70
+ function shellGlob(pattern: string): RegExp {
71
+ let source = "^";
72
+ for (const character of pattern) {
73
+ if (character === "*") source += ".*";
74
+ else if (character === "?") source += ".";
75
+ else source += character.replace(/[\\^$.*+()[\]{}|]/g, "\\$&");
76
+ }
77
+ return new RegExp(`${source}$`);
78
+ }
79
+
80
+ function matchesAnyPattern(path: string, patterns: string): boolean {
81
+ return patterns.split("|").some((pattern) => shellGlob(pattern).test(path));
82
+ }
83
+
84
+ export function fileTypeForPath(filePath: string): string {
85
+ for (const [fileType, patterns] of FILE_TYPE_PATTERNS) {
86
+ if (matchesAnyPattern(filePath, patterns)) return fileType;
87
+ }
88
+ return "";
89
+ }
90
+
91
+ function textValue(value: unknown): string {
92
+ if (typeof value === "string") return value;
93
+ if (value === null || value === undefined) return "";
94
+ return typeof value === "object" ? JSON.stringify(value) : String(value);
95
+ }
96
+
97
+ export function handleFileTypeReact(rawInput: string, runtime: HookRuntime = {}): FileTypeReactResult {
98
+ const stateDir = resolveHookStateDir(runtime);
99
+ const statusFile = join(stateDir, "status.json");
100
+ if (!fileExists(statusFile)) return { updated: false };
101
+
102
+ const config = readJsonFile<FileConfig>(join(stateDir, "config.json")) ?? {};
103
+ const cooldown = nonNegativeInteger(config.commentCooldown, 30);
104
+ const sid = resolveHookSessionId(runtime);
105
+ const now = runtime.now?.() ?? Date.now();
106
+ if (isOnCooldown(join(stateDir, `.last_reaction.${sid}`), cooldown, now)) {
107
+ return { updated: false };
108
+ }
109
+
110
+ const parsedStatus = readJsonFile<BuddyStatus>(statusFile);
111
+ const status = parsedStatus ?? {};
112
+ if (status.muted === true || status.muted === "true") return { updated: false };
113
+
114
+ const input = parseHookInput(rawInput);
115
+ if (!input) return { updated: false };
116
+ const filePath = textValue(input.file_path);
117
+ if (!filePath) return { updated: false };
118
+
119
+ if ((runtime.random?.() ?? Math.random()) * 100 >= 15) return { updated: false };
120
+
121
+ let fileType = fileTypeForPath(filePath);
122
+ if (!fileType) return { updated: false };
123
+ if (fileType === "lang-javascript") fileType = "lang-typescript";
124
+
125
+ const species = textValue(status.species) || "blob";
126
+ const reaction = pickRandom(fileReactionPool(species, fileType), runtime);
127
+ if (!reaction) return { updated: false };
128
+
129
+ mkdirSync(stateDir, { recursive: true });
130
+ writeFileSync(join(stateDir, `.last_reaction.${sid}`), String(Math.floor(now / 1000)));
131
+ writeFileSync(
132
+ join(stateDir, `reaction.${sid}.json`),
133
+ JSON.stringify({ reaction, timestamp: Math.floor(now / 1000) * 1000, reason: fileType }),
134
+ );
135
+ if (parsedStatus) writeFileSync(statusFile, JSON.stringify({ ...status, reaction }, null, 2));
136
+
137
+ return { fileType, reaction, updated: true };
138
+ }
139
+
140
+ if (import.meta.main) {
141
+ try {
142
+ handleFileTypeReact(await readStdin());
143
+ } catch {
144
+ // Claude Code hooks must never fail the turn.
145
+ }
146
+ process.exit(0);
147
+ }
@@ -0,0 +1,107 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { mkdirSync, writeFileSync } from "fs";
4
+ import { join } from "path";
5
+ import { moodReactionPool } from "./reaction-data.ts";
6
+ import {
7
+ fileExists,
8
+ isOnCooldown,
9
+ nonNegativeInteger,
10
+ parseHookInput,
11
+ pickRandom,
12
+ readJsonFile,
13
+ readStdin,
14
+ resolveHookSessionId,
15
+ resolveHookStateDir,
16
+ stringField,
17
+ type HookRuntime,
18
+ } from "./common.ts";
19
+
20
+ interface BuddyStatus {
21
+ muted?: unknown;
22
+ species?: unknown;
23
+ reaction?: unknown;
24
+ [key: string]: unknown;
25
+ }
26
+
27
+ interface MoodConfig {
28
+ moodCooldown?: unknown;
29
+ }
30
+
31
+ interface Events {
32
+ [key: string]: unknown;
33
+ }
34
+
35
+ export interface MoodReactResult {
36
+ mood?: "frustrated" | "happy" | "stuck";
37
+ reaction?: string;
38
+ updated: boolean;
39
+ }
40
+
41
+ const MOOD_PATTERNS: ReadonlyArray<readonly [MoodReactResult["mood"], RegExp]> = [
42
+ ["frustrated", /\bwtf\b|\bugh\b|\bstupid\b|\bbroken\b|why won.?t|\bcome on\b|\bseriously\b|\bdamn\b|\bhell\b|this sucks|hate this|\bgrr\b|\bargh\b|\bannoying\b|\bfrustrat\b|\bterrible\b|\bhorrible\b|\bworst\b/i],
43
+ ["happy", /\bnice!?\b|\bworks!?\b|\bawesome\b|\bperfect\b|\bgreat\b|love it|\byay\b|\bsweet\b|\bbeautiful\b|\bamazing\b|hell yes|nailed it|fixed it|\bhell yeah\b|\bfantastic\b/i],
44
+ ["stuck", /\bstuck\b|\bhelp\b|\bconfused\b|don.?t understand|how do i\b|what does\b|why is\b|i can.?t\b|no idea|\blost\b|\bclueless\b|\bbaffled\b|\bpuzzled\b/i],
45
+ ];
46
+
47
+ export function moodForPrompt(prompt: string): MoodReactResult["mood"] {
48
+ return MOOD_PATTERNS.find(([, pattern]) => pattern.test(prompt))?.[0];
49
+ }
50
+
51
+ function incrementEvent(stateDir: string, key: string): void {
52
+ const eventsFile = join(stateDir, "events.json");
53
+ const parsedEvents = readJsonFile<Events>(eventsFile);
54
+ if (!parsedEvents && fileExists(eventsFile)) return;
55
+ const events = parsedEvents ?? {};
56
+ events[key] = typeof events[key] === "number" ? Number(events[key]) + 1 : 1;
57
+ writeFileSync(eventsFile, JSON.stringify(events, null, 2));
58
+ }
59
+
60
+ export function handleMoodReact(rawInput: string, runtime: HookRuntime = {}): MoodReactResult {
61
+ const stateDir = resolveHookStateDir(runtime);
62
+ const statusFile = join(stateDir, "status.json");
63
+ if (!fileExists(statusFile)) return { updated: false };
64
+
65
+ const config = readJsonFile<MoodConfig>(join(stateDir, "config.json")) ?? {};
66
+ const cooldown = nonNegativeInteger(config.moodCooldown, 60);
67
+ const sid = resolveHookSessionId(runtime);
68
+ const now = runtime.now?.() ?? Date.now();
69
+ if (isOnCooldown(join(stateDir, `.last_mood.${sid}`), cooldown, now)) {
70
+ return { updated: false };
71
+ }
72
+
73
+ const input = parseHookInput(rawInput);
74
+ if (!input) return { updated: false };
75
+ const prompt = stringField(input, "prompt");
76
+ if (!prompt) return { updated: false };
77
+
78
+ const parsedStatus = readJsonFile<BuddyStatus>(statusFile);
79
+ const status = parsedStatus ?? {};
80
+ if (status.muted === true || status.muted === "true") return { updated: false };
81
+
82
+ const mood = moodForPrompt(prompt);
83
+ if (!mood) return { updated: false };
84
+ const species = typeof status.species === "string" ? status.species : "blob";
85
+ const reaction = pickRandom(moodReactionPool(species, mood), runtime);
86
+ if (!reaction) return { updated: false };
87
+
88
+ mkdirSync(stateDir, { recursive: true });
89
+ writeFileSync(join(stateDir, `.last_mood.${sid}`), String(Math.floor(now / 1000)));
90
+ writeFileSync(
91
+ join(stateDir, `reaction.${sid}.json`),
92
+ JSON.stringify({ reaction, timestamp: Math.floor(now / 1000) * 1000, reason: mood }),
93
+ );
94
+ if (parsedStatus) writeFileSync(statusFile, JSON.stringify({ ...status, reaction }, null, 2));
95
+ incrementEvent(stateDir, `mood_${mood}`);
96
+
97
+ return { mood, reaction, updated: true };
98
+ }
99
+
100
+ if (import.meta.main) {
101
+ try {
102
+ handleMoodReact(await readStdin());
103
+ } catch {
104
+ // Claude Code hooks must never fail the turn.
105
+ }
106
+ process.exit(0);
107
+ }
@@ -0,0 +1,98 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { mkdirSync, writeFileSync } from "fs";
4
+ import { join } from "path";
5
+ import {
6
+ DEFAULT_NAME_REACTIONS,
7
+ NAME_REACTION_POOLS,
8
+ } from "./reaction-data.ts";
9
+ import {
10
+ fileExists,
11
+ parseHookInput,
12
+ pickRandom,
13
+ readJsonFile,
14
+ readStdin,
15
+ resolveHookSessionId,
16
+ resolveHookStateDir,
17
+ type JsonObject,
18
+ type HookRuntime,
19
+ } from "./common.ts";
20
+
21
+ interface BuddyStatus {
22
+ muted?: unknown;
23
+ name?: unknown;
24
+ species?: unknown;
25
+ reaction?: unknown;
26
+ [key: string]: unknown;
27
+ }
28
+
29
+ export interface NameReactResult {
30
+ reaction?: string;
31
+ updated: boolean;
32
+ }
33
+
34
+ function promptFromInput(input: JsonObject): string {
35
+ const direct = input.prompt ?? input.message ?? input.user_message;
36
+ if (direct !== undefined && direct !== null) {
37
+ return typeof direct === "string" ? direct : String(direct);
38
+ }
39
+
40
+ const messages = input.messages;
41
+ if (!Array.isArray(messages) || messages.length === 0) return "";
42
+ const content = (messages[messages.length - 1] as JsonObject | null)?.content;
43
+ if (Array.isArray(content)) {
44
+ const first = content[0];
45
+ return first && typeof first === "object" ? String((first as JsonObject).text ?? "") : "";
46
+ }
47
+ return typeof content === "string" ? content : "";
48
+ }
49
+
50
+ export function hasNameMention(prompt: string, name: string): boolean {
51
+ if (!name) return false;
52
+ try {
53
+ return new RegExp(`(^|[^a-zA-Z])${name}([^a-zA-Z]|$)`, "i").test(prompt);
54
+ } catch {
55
+ return false;
56
+ }
57
+ }
58
+
59
+ export function handleNameReact(rawInput: string, runtime: HookRuntime = {}): NameReactResult {
60
+ const stateDir = resolveHookStateDir(runtime);
61
+ const statusFile = join(stateDir, "status.json");
62
+ if (!fileExists(statusFile)) return { updated: false };
63
+
64
+ const input = parseHookInput(rawInput);
65
+ if (!input) return { updated: false };
66
+ const prompt = promptFromInput(input);
67
+ if (!prompt) return { updated: false };
68
+
69
+ const parsedStatus = readJsonFile<BuddyStatus>(statusFile);
70
+ const status = parsedStatus ?? {};
71
+ const name = typeof status.name === "string" ? status.name : "";
72
+ if (!hasNameMention(prompt, name)) return { updated: false };
73
+ if (status.muted === true || status.muted === "true") return { updated: false };
74
+
75
+ const species = typeof status.species === "string" ? status.species : "blob";
76
+ const reaction = pickRandom(NAME_REACTION_POOLS[species] ?? DEFAULT_NAME_REACTIONS, runtime);
77
+ if (!reaction) return { updated: false };
78
+
79
+ const now = runtime.now?.() ?? Date.now();
80
+ const sid = resolveHookSessionId(runtime);
81
+ mkdirSync(stateDir, { recursive: true });
82
+ if (parsedStatus) writeFileSync(statusFile, JSON.stringify({ ...status, reaction }, null, 2));
83
+ writeFileSync(
84
+ join(stateDir, `reaction.${sid}.json`),
85
+ JSON.stringify({ reaction, timestamp: Math.floor(now / 1000) * 1000, reason: "name" }),
86
+ );
87
+
88
+ return { reaction, updated: true };
89
+ }
90
+
91
+ if (import.meta.main) {
92
+ try {
93
+ handleNameReact(await readStdin());
94
+ } catch {
95
+ // Claude Code hooks must never fail the turn.
96
+ }
97
+ process.exit(0);
98
+ }