@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.
package/hooks/suggest.sh CHANGED
@@ -1,55 +1,11 @@
1
1
  #!/usr/bin/env bash
2
- # coding-buddy Stop hook — proactive suggestion detection
3
- # Detects coding patterns and offers suggestions when relevant.
4
- #
5
- # Combined with buddy-comment.sh to avoid running two separate Stop hooks.
6
-
7
- # shellcheck source=../scripts/paths.sh
8
- source "$(dirname "${BASH_SOURCE[0]}")/../scripts/paths.sh"
9
-
10
- STATE_DIR="$BUDDY_STATE_DIR"
11
- CONFIG_FILE="$STATE_DIR/config.json"
12
- LAST_SUGGESTION_FILE="$STATE_DIR/.last_suggestion"
13
- SUGGESTIONS_FILE="$STATE_DIR/suggestions.json"
14
-
15
- [ -f "$CONFIG_FILE" ] || exit 0
16
-
17
- # Check if suggestions are enabled (default: true)
18
- SUGGESTIONS_ENABLED="true"
19
- if [ -f "$CONFIG_FILE" ]; then
20
- _enabled=$(jq -r '.suggestionsEnabled // true' "$CONFIG_FILE" 2>/dev/null)
21
- [ "$_enabled" = "false" ] && exit 0
22
- fi
23
-
24
- # Check cooldown (default: 180s = 3 min)
25
- SUGGESTION_COOLDOWN=180
26
- if [ -f "$CONFIG_FILE" ]; then
27
- _cd=$(jq -r '.suggestionCooldown // 180' "$CONFIG_FILE" 2>/dev/null || echo 180)
28
- [[ "$_cd" =~ ^[0-9]+$ ]] && SUGGESTION_COOLDOWN=$_cd
29
- fi
30
-
31
- if [ -f "$LAST_SUGGESTION_FILE" ]; then
32
- LAST=$(cat "$LAST_SUGGESTION_FILE" 2>/dev/null)
33
- NOW=$(date +%s)
34
- DIFF=$(( NOW - ${LAST:-0} ))
35
- [ "$DIFF" -lt "$SUGGESTION_COOLDOWN" ] && exit 0
36
- fi
37
-
38
- INPUT=$(cat)
39
-
40
- # Extract messages
41
- ASSISTANT_MSG=$(echo "$INPUT" | jq -r '.last_assistant_message // ""' 2>/dev/null)
42
- [ -z "$ASSISTANT_MSG" ] && exit 0
43
-
44
- # Record turn and check patterns
45
- PLUGIN_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
46
-
47
- # Check for patterns via the suggestions script
48
- if [ -x "$(command -v bun)" ]; then
49
- # Run the suggestion check (async, non-blocking)
50
- bun run "$PLUGIN_ROOT/server/check-suggestions.ts" \
51
- "$(echo "$ASSISTANT_MSG" | jq -Rs .)" \
52
- >/dev/null 2>&1 &
53
- fi
54
-
55
- 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/suggest.ts" || exit 0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ramarivera/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",
5
5
  "type": "module",
6
6
  "omp": {
@@ -0,0 +1,93 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { mkdirSync, writeFileSync } from "fs";
4
+ import { join } from "path";
5
+ import {
6
+ defaultSpawnDetached,
7
+ fileExists,
8
+ isOnCooldown,
9
+ nonNegativeInteger,
10
+ parseHookInput,
11
+ readJsonFile,
12
+ readStdin,
13
+ resolveHookSessionId,
14
+ resolveHookStateDir,
15
+ stringField,
16
+ type HookRuntime,
17
+ } from "./common.ts";
18
+
19
+ interface BuddyConfig {
20
+ commentCooldown?: unknown;
21
+ }
22
+
23
+ interface Events {
24
+ turns?: number;
25
+ [key: string]: unknown;
26
+ }
27
+
28
+ const BUDDY_COMMENT_PATTERN = /<!--\s*buddy:\s*([\s\S]*?)\s*-->/g;
29
+
30
+ export interface BuddyCommentResult {
31
+ comment?: string;
32
+ updated: boolean;
33
+ }
34
+
35
+ export function extractBuddyComment(message: string): string {
36
+ let comment = "";
37
+ for (const match of message.matchAll(BUDDY_COMMENT_PATTERN)) {
38
+ const candidate = match[1]?.trim();
39
+ if (candidate) comment = candidate;
40
+ }
41
+ return comment;
42
+ }
43
+
44
+ export function handleBuddyComment(rawInput: string, runtime: HookRuntime = {}): BuddyCommentResult {
45
+ const stateDir = resolveHookStateDir(runtime);
46
+ if (!fileExists(join(stateDir, "status.json"))) return { updated: false };
47
+
48
+ const input = parseHookInput(rawInput);
49
+ if (!input) return { updated: false };
50
+
51
+ const assistantMessage = stringField(input, "last_assistant_message");
52
+ if (!assistantMessage) return { updated: false };
53
+
54
+ const comment = extractBuddyComment(assistantMessage);
55
+ if (!comment) return { updated: false };
56
+
57
+ const now = runtime.now?.() ?? Date.now();
58
+ const sid = resolveHookSessionId(runtime);
59
+ const config = readJsonFile<BuddyConfig>(join(stateDir, "config.json")) ?? {};
60
+ const cooldown = nonNegativeInteger(config.commentCooldown, 30);
61
+ const cooldownFile = join(stateDir, `.last_comment.${sid}`);
62
+ if (isOnCooldown(cooldownFile, cooldown, now)) return { updated: false };
63
+
64
+ mkdirSync(stateDir, { recursive: true });
65
+ writeFileSync(cooldownFile, String(Math.floor(now / 1000)));
66
+ writeFileSync(
67
+ join(stateDir, `reaction.${sid}.json`),
68
+ JSON.stringify({ reaction: comment, timestamp: now, reason: "turn" }),
69
+ );
70
+
71
+ const eventsFile = join(stateDir, "events.json");
72
+ const events = readJsonFile<Events>(eventsFile) ?? {};
73
+ events.turns = (typeof events.turns === "number" ? events.turns : 0) + 1;
74
+ writeFileSync(eventsFile, JSON.stringify(events, null, 2));
75
+
76
+ const spawnDetached = runtime.spawnDetached ?? defaultSpawnDetached(runtime);
77
+ spawnDetached("server/award-xp.ts", ["turn"]);
78
+ spawnDetached("server/consolidate.ts", [
79
+ assistantMessage,
80
+ stringField(input, "last_user_message"),
81
+ ]);
82
+
83
+ return { comment, updated: true };
84
+ }
85
+
86
+ if (import.meta.main) {
87
+ try {
88
+ handleBuddyComment(await readStdin());
89
+ } catch {
90
+ // Claude Code hooks must never fail the turn.
91
+ }
92
+ process.exit(0);
93
+ }
@@ -0,0 +1,95 @@
1
+ import { spawn } from "child_process";
2
+ import { existsSync, readFileSync } from "fs";
3
+ import { join, resolve } from "path";
4
+ import { buddyStateDir } from "../path.ts";
5
+
6
+ export type JsonObject = Record<string, unknown>;
7
+
8
+ export interface HookRuntime {
9
+ now?: () => number;
10
+ packageRoot?: string;
11
+ sessionId?: string;
12
+ spawnDetached?: (script: string, args: string[]) => void;
13
+ stateDir?: string;
14
+ }
15
+
16
+ export function parseHookInput(raw: string): JsonObject | null {
17
+ try {
18
+ const value = JSON.parse(raw) as unknown;
19
+ return value && typeof value === "object" && !Array.isArray(value)
20
+ ? value as JsonObject
21
+ : null;
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+
27
+ export function stringField(input: JsonObject, field: string): string {
28
+ const value = input[field];
29
+ return typeof value === "string" ? value : "";
30
+ }
31
+
32
+ export function resolveHookStateDir(runtime: HookRuntime = {}): string {
33
+ return runtime.stateDir ?? buddyStateDir();
34
+ }
35
+
36
+ export function resolveHookSessionId(runtime: HookRuntime = {}): string {
37
+ if (runtime.sessionId) return runtime.sessionId;
38
+ const ccSid = process.env.CLAUDE_CODE_SESSION_ID;
39
+ if (ccSid) return ccSid.slice(0, 8);
40
+ const pane = process.env.TMUX_PANE;
41
+ if (pane) return pane.replace(/^%/, "");
42
+ return "default";
43
+ }
44
+
45
+ export function readJsonFile<T>(path: string): T | null {
46
+ try {
47
+ return JSON.parse(readFileSync(path, "utf8")) as T;
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+
53
+ export function nonNegativeInteger(value: unknown, fallback: number): number {
54
+ if (Number.isInteger(value) && Number(value) >= 0) return Number(value);
55
+ if (typeof value === "string" && /^[0-9]+$/.test(value)) return Number(value);
56
+ return fallback;
57
+ }
58
+
59
+ export function isOnCooldown(path: string, cooldownSeconds: number, nowMs: number): boolean {
60
+ try {
61
+ const last = Number.parseInt(readFileSync(path, "utf8").trim(), 10);
62
+ if (!Number.isFinite(last)) return false;
63
+ return Math.floor(nowMs / 1000) - last < cooldownSeconds;
64
+ } catch {
65
+ return false;
66
+ }
67
+ }
68
+
69
+ export function packageRootFromHooks(): string {
70
+ return resolve(import.meta.dir, "..", "..");
71
+ }
72
+
73
+ export function defaultSpawnDetached(runtime: HookRuntime = {}): (script: string, args: string[]) => void {
74
+ return (script, args) => {
75
+ const root = runtime.packageRoot ?? packageRootFromHooks();
76
+ const child = spawn(process.execPath, ["run", join(root, script), ...args], {
77
+ cwd: root,
78
+ detached: true,
79
+ stdio: "ignore",
80
+ });
81
+ child.unref();
82
+ };
83
+ }
84
+
85
+ export function fileExists(path: string): boolean {
86
+ try {
87
+ return existsSync(path);
88
+ } catch {
89
+ return false;
90
+ }
91
+ }
92
+
93
+ export async function readStdin(): Promise<string> {
94
+ return await Bun.stdin.text();
95
+ }
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { spawnSync } from "child_process";
4
+ import { existsSync } from "fs";
5
+ import { join } from "path";
6
+ import { packageRootFromHooks, readStdin } from "./common.ts";
7
+
8
+ const ALLOWED = new Set([
9
+ "react.sh",
10
+ "file-type-react.sh",
11
+ "name-react.sh",
12
+ "mood-react.sh",
13
+ ]);
14
+
15
+ if (import.meta.main) {
16
+ try {
17
+ const script = process.argv[2] ?? "";
18
+ if (ALLOWED.has(script)) {
19
+ const root = packageRootFromHooks();
20
+ const hook = join(root, "hooks", "legacy", script);
21
+ if (existsSync(hook)) {
22
+ spawnSync("bash", [hook], {
23
+ cwd: root,
24
+ input: await readStdin(),
25
+ stdio: ["pipe", "ignore", "ignore"],
26
+ timeout: 14_000,
27
+ });
28
+ }
29
+ }
30
+ } catch {
31
+ // Claude Code hooks must never fail the turn.
32
+ }
33
+ process.exit(0);
34
+ }
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { join } from "path";
4
+ import {
5
+ defaultSpawnDetached,
6
+ fileExists,
7
+ isOnCooldown,
8
+ nonNegativeInteger,
9
+ parseHookInput,
10
+ readJsonFile,
11
+ readStdin,
12
+ resolveHookStateDir,
13
+ stringField,
14
+ type HookRuntime,
15
+ } from "./common.ts";
16
+
17
+ interface SuggestConfig {
18
+ suggestionCooldown?: unknown;
19
+ suggestionsEnabled?: unknown;
20
+ }
21
+
22
+ export interface SuggestHookResult {
23
+ checked: boolean;
24
+ }
25
+
26
+ export function handleSuggest(rawInput: string, runtime: HookRuntime = {}): SuggestHookResult {
27
+ const stateDir = resolveHookStateDir(runtime);
28
+ const configFile = join(stateDir, "config.json");
29
+ if (!fileExists(configFile)) return { checked: false };
30
+
31
+ const config = readJsonFile<SuggestConfig>(configFile) ?? {};
32
+ if (config.suggestionsEnabled === false || config.suggestionsEnabled === "false") {
33
+ return { checked: false };
34
+ }
35
+
36
+ const now = runtime.now?.() ?? Date.now();
37
+ const cooldown = nonNegativeInteger(config.suggestionCooldown, 180);
38
+ if (isOnCooldown(join(stateDir, ".last_suggestion"), cooldown, now)) {
39
+ return { checked: false };
40
+ }
41
+
42
+ const input = parseHookInput(rawInput);
43
+ if (!input) return { checked: false };
44
+
45
+ const assistantMessage = stringField(input, "last_assistant_message");
46
+ if (!assistantMessage) return { checked: false };
47
+
48
+ const spawnDetached = runtime.spawnDetached ?? defaultSpawnDetached(runtime);
49
+ spawnDetached("server/check-suggestions.ts", [JSON.stringify(assistantMessage)]);
50
+ return { checked: true };
51
+ }
52
+
53
+ if (import.meta.main) {
54
+ try {
55
+ handleSuggest(await readStdin());
56
+ } catch {
57
+ // Claude Code hooks must never fail the turn.
58
+ }
59
+ process.exit(0);
60
+ }
package/server/index.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
10
10
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
11
11
  import { z } from "zod";
12
+ import { existsSync } from "fs";
12
13
  import { join, resolve, dirname } from "path";
13
14
 
14
15
  import { generateBones,
@@ -45,6 +46,7 @@ import {
45
46
  } from "./state";
46
47
  import {
47
48
  buddyStateDir,
49
+ buddyAppDir,
48
50
  claudeConfigDir,
49
51
  claudeSettingsPath,
50
52
  } from "./path";
@@ -622,7 +624,7 @@ server.tool("buddy_unmute", "Unmute buddy reactions", {}, async () => {
622
624
 
623
625
  server.tool(
624
626
  "buddy_statusline",
625
- "Enable or disable the buddy status line, and toggle combined mode (shows rate-limit usage bars alongside the buddy). Returns current status if called without arguments.",
627
+ "Enable or disable the buddy status line, toggle combined mode (shows rate-limit usage bars alongside the buddy), or set a cached asynchronous sub-status command. Returns current status if called without arguments.",
626
628
  {
627
629
  enabled: z
628
630
  .boolean()
@@ -636,17 +638,24 @@ server.tool(
636
638
  .describe(
637
639
  "true to show rate-limit usage bars alongside buddy (requires python3), false for buddy-only mode.",
638
640
  ),
641
+ subStatusCommand: z
642
+ .string()
643
+ .optional()
644
+ .describe(
645
+ "Shell command whose cached output is appended below the buddy panel. Empty string clears it; the command always runs asynchronously.",
646
+ ),
639
647
  },
640
- async ({ enabled, combined }) => {
641
- if (enabled === undefined && combined === undefined) {
648
+ async ({ enabled, combined, subStatusCommand }) => {
649
+ if (enabled === undefined && combined === undefined && subStatusCommand === undefined) {
642
650
  const cfg = loadConfig();
643
651
  const state = cfg.statusLineEnabled ? "enabled" : "disabled";
644
652
  const mode = cfg.useCombinedStatus ? "combined (with rate-limit bars)" : "basic (buddy only)";
653
+ const subStatus = cfg.subStatusCommand ?? "unset";
645
654
  return {
646
655
  content: [
647
656
  {
648
657
  type: "text",
649
- text: `Status line: ${state}\nMode: ${mode}\nUse /buddy statusline on|off to toggle, /buddy statusline combined to add rate-limit bars.\nRestart Claude Code after changes for them to take effect.`,
658
+ text: `Status line: ${state}\nMode: ${mode}\nSub-status command: ${subStatus}\nUse /buddy statusline on|off to toggle, /buddy statusline combined to add rate-limit bars, or set subStatusCommand to append cached command output.\nRestart Claude Code after changes for them to take effect.`,
650
659
  },
651
660
  ],
652
661
  };
@@ -656,6 +665,10 @@ server.tool(
656
665
  saveConfig({ useCombinedStatus: combined });
657
666
  }
658
667
 
668
+ if (subStatusCommand !== undefined) {
669
+ saveConfig({ subStatusCommand: subStatusCommand.trim() ? subStatusCommand : undefined });
670
+ }
671
+
659
672
  if (enabled !== undefined) {
660
673
  saveConfig({ statusLineEnabled: enabled });
661
674
  }
@@ -663,7 +676,10 @@ server.tool(
663
676
  const cfg = loadConfig();
664
677
 
665
678
  if (cfg.statusLineEnabled) {
666
- const pluginRoot = resolve(dirname(import.meta.dir));
679
+ const installedRoot = buddyAppDir();
680
+ const pluginRoot = existsSync(join(installedRoot, "statusline", "buddy-status.sh"))
681
+ ? installedRoot
682
+ : resolve(dirname(import.meta.dir));
667
683
  const scriptName = cfg.useCombinedStatus ? "combined-status.sh" : "buddy-status.sh";
668
684
  const statusScript = join(pluginRoot, "statusline", scriptName);
669
685
  setBuddyStatusLine(statusScript);
package/server/path.ts CHANGED
@@ -80,3 +80,8 @@ export function buddyStateDir(): string {
80
80
  if (cfgDir) return join(cfgDir, "buddy-state");
81
81
  return join(homedir(), ".claude-buddy");
82
82
  }
83
+
84
+ /** Stable per-profile runtime copy used by installer registrations. */
85
+ export function buddyAppDir(): string {
86
+ return join(buddyStateDir(), "app");
87
+ }
package/server/state.ts CHANGED
@@ -324,6 +324,7 @@ export interface BuddyConfig {
324
324
  bubbleWidth: number;
325
325
  bubbleMargin: number;
326
326
  useCombinedStatus: boolean;
327
+ subStatusCommand?: string;
327
328
  rainbowColors?: string[];
328
329
  theme: "dark" | "light" | "auto";
329
330
  moodEnabled: boolean;
@@ -349,12 +350,22 @@ const DEFAULT_CONFIG: BuddyConfig = {
349
350
  suggestionCooldown: 180,
350
351
  };
351
352
 
353
+ export function normalizeConfig(data: unknown): BuddyConfig {
354
+ const values = data && typeof data === "object" && !Array.isArray(data)
355
+ ? data
356
+ : {};
357
+ const config = { ...DEFAULT_CONFIG, ...values } as BuddyConfig;
358
+ if (typeof config.subStatusCommand !== "string" || config.subStatusCommand.trim() === "") {
359
+ delete config.subStatusCommand;
360
+ }
361
+ return config;
362
+ }
363
+
352
364
  export function loadConfig(): BuddyConfig {
353
365
  try {
354
- const data = JSON.parse(readFileSync(CONFIG_FILE, "utf8"));
355
- return { ...DEFAULT_CONFIG, ...data };
366
+ return normalizeConfig(JSON.parse(readFileSync(CONFIG_FILE, "utf8")));
356
367
  } catch {
357
- return { ...DEFAULT_CONFIG };
368
+ return normalizeConfig({});
358
369
  }
359
370
  }
360
371
 
@@ -507,6 +518,7 @@ const TRANSIENT_PREFIXES = [
507
518
  "reaction.",
508
519
  ".last_reaction.",
509
520
  ".last_comment.",
521
+ ".substatus.",
510
522
  ];
511
523
 
512
524
  /**
@@ -16,6 +16,7 @@
16
16
 
17
17
  # shellcheck source=../scripts/paths.sh
18
18
  source "$(dirname "${BASH_SOURCE[0]}")/../scripts/paths.sh"
19
+ source "$(dirname "${BASH_SOURCE[0]}")/substatus.sh"
19
20
 
20
21
  STATE="$BUDDY_STATE_DIR/status.json"
21
22
  CONFIG_FILE="$BUDDY_STATE_DIR/config.json"
@@ -31,6 +32,7 @@ NAME=$(jq -r '.name // ""' "$STATE" 2>/dev/null)
31
32
  [ -z "$NAME" ] && exit 0
32
33
 
33
34
  RARITY=$(jq -r '.rarity // "common"' "$STATE" 2>/dev/null)
35
+ STARS=$(jq -r '.stars // ""' "$STATE" 2>/dev/null)
34
36
  SHINY=$(jq -r '.shiny // false' "$STATE" 2>/dev/null)
35
37
  REACTION_FILE="$BUDDY_STATE_DIR/reaction.$SID.json"
36
38
  REACTION=$(jq -r '.reaction // ""' "$REACTION_FILE" 2>/dev/null)
@@ -38,7 +40,7 @@ ACHIEVEMENT=$(jq -r '.achievement // ""' "$STATE" 2>/dev/null)
38
40
  LEVEL=$(jq -r '.level // 1' "$STATE" 2>/dev/null)
39
41
  MOOD=$(jq -r '.mood // "focused"' "$STATE" 2>/dev/null)
40
42
 
41
- cat > /dev/null # drain stdin
43
+ BUDDY_STATUSLINE_INPUT=$(cat)
42
44
 
43
45
  # ─── Animation: pick current frame from server-rendered frames ──────────────
44
46
  NOW=${BUDDY_FAKE_NOW:-$(date +%s)}
@@ -67,6 +69,7 @@ if [ -f "$CONFIG_FILE" ]; then
67
69
  fi
68
70
 
69
71
  NC=$'\033[0m'
72
+ NEUTRAL=$'\033[39m'
70
73
  case "$RARITY" in
71
74
  common)
72
75
  [ "$_THEME" = "light" ] && C=$'\033[38;2;90;90;90m' || C=$'\033[38;2;153;153;153m' ;;
@@ -188,6 +191,7 @@ fi
188
191
  # so the geometric center sits at col 6.
189
192
  NAME_WITH_LEVEL="$NAME"
190
193
  [ "$LEVEL" -gt 1 ] 2>/dev/null && NAME_WITH_LEVEL="${NAME} [L${LEVEL}]"
194
+ [ -n "$STARS" ] && NAME_WITH_LEVEL="${NAME_WITH_LEVEL} ${STARS}"
191
195
  case "$MOOD" in
192
196
  happy) MOOD_EMOJI="" ;;
193
197
  focused) MOOD_EMOJI="" ;;
@@ -214,11 +218,11 @@ for line in "${ART_LINES[@]}"; do
214
218
  if [ "$SHINY" = "true" ]; then
215
219
  ALL_COLORS+=("${RAINBOW[$(( (_arc + RAINBOW_OFFSET) % RAINBOW_LEN ))]}")
216
220
  else
217
- ALL_COLORS+=("$C")
221
+ ALL_COLORS+=("$NEUTRAL")
218
222
  fi
219
223
  _arc=$(( _arc + 1 ))
220
224
  done
221
- ALL_LINES+=("$NAME_LINE"); ALL_COLORS+=("$DIM")
225
+ ALL_LINES+=("$NAME_LINE"); ALL_COLORS+=("$C")
222
226
 
223
227
  case "$SPECIES" in
224
228
  axolotl|capybara|rabbit|mushroom|chonk) ART_W=10 ;;
@@ -377,18 +381,18 @@ for (( i=0; i<MAX_LINES; i++ )); do
377
381
 
378
382
  # Connector: "-- " on the middle text line, spaces otherwise
379
383
  if [ $bi -eq $CONNECTOR_BI ]; then
380
- gap="${C}--${NC} "
384
+ gap="${NEUTRAL}--${NC} "
381
385
  else
382
386
  gap=" "
383
387
  fi
384
388
 
385
389
  if [ "$btype" = "border" ]; then
386
- echo "${SPACER}${C}${bline}${NC}${gap}${art_part}"
390
+ echo "${SPACER}${NEUTRAL}${bline}${NC}${gap}${art_part}"
387
391
  else
388
392
  pipe_l="${bline:0:1}"
389
393
  pipe_r="${bline: -1}"
390
394
  inner="${bline:1:$(( ${#bline} - 2 ))}"
391
- echo "${SPACER}${C}${pipe_l}${NC}${DIM}${inner}${NC}${C}${pipe_r}${NC}${gap}${art_part}"
395
+ echo "${SPACER}${NEUTRAL}${pipe_l}${NC}${DIM}${inner}${NC}${NEUTRAL}${pipe_r}${NC}${gap}${art_part}"
392
396
  fi
393
397
  else
394
398
  empty=$(printf '%*s' "$BOX_W" '')
@@ -399,4 +403,8 @@ for (( i=0; i<MAX_LINES; i++ )); do
399
403
  fi
400
404
  done
401
405
 
406
+ # Append the last cached sub-status result below the buddy panel and refresh
407
+ # it asynchronously when stale. The statusline itself never waits on it.
408
+ append_substatus
409
+
402
410
  exit 0
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env bash
2
2
  # statusline/combined-status.sh
3
3
  # Two-panel status line: rate-limit stats left, buddy art right.
4
- # buddy-status.sh is intentionally untouched (kept clean for upstream PR).
4
+ # buddy-status.sh renders the buddy panel and shared cached sub-status below it.
5
5
 
6
6
  [ "$BUDDY_SHELL" = "1" ] && exit 0
7
7
 
@@ -54,7 +54,7 @@ except Exception:
54
54
  " 2>/dev/null)
55
55
 
56
56
  # ── Capture buddy art (buddy reads state files, not stdin) ───────────────────
57
- BUDDY_OUTPUT=$("$BUDDY_SCRIPT" </dev/null 2>/dev/null)
57
+ BUDDY_OUTPUT=$(printf '%s' "$STDIN_DATA" | "$BUDDY_SCRIPT" 2>/dev/null)
58
58
 
59
59
  # No buddy output → exit silently (muted, no state, etc.)
60
60
  [ -z "$BUDDY_OUTPUT" ] && exit 0
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env bash
2
+ # Shared cached sub-status renderer for the buddy statusline scripts.
3
+ #
4
+ # The command is intentionally launched from a detached process. Claude Code
5
+ # kills a statusline process when the next one starts, so running the command
6
+ # inline would make slow commands disappear before they can finish.
7
+
8
+ _substatus_mtime() {
9
+ local path="$1"
10
+ local value
11
+ value=$(stat -f %m "$path" 2>/dev/null)
12
+ case "$value" in
13
+ ''|*[!0-9]*) value=$(stat -c %Y "$path" 2>/dev/null) ;;
14
+ esac
15
+ case "$value" in
16
+ ''|*[!0-9]*) echo 0 ;;
17
+ *) echo "$value" ;;
18
+ esac
19
+ }
20
+
21
+ append_substatus() {
22
+ local state_dir="$BUDDY_STATE_DIR"
23
+ local sid="${BUDDY_SID:-default}"
24
+ local config_file="$state_dir/config.json"
25
+ local cache_file="$state_dir/.substatus.$sid"
26
+ local lock_dir="$state_dir/.substatus.$sid.lock"
27
+ local command=""
28
+ local now cache_age lock_age
29
+
30
+ [ -f "$config_file" ] || return 0
31
+ command=$(jq -r '.subStatusCommand // ""' "$config_file" 2>/dev/null)
32
+ [ -n "$command" ] || return 0
33
+
34
+ # Always show the last complete result first. A missing or stale cache is
35
+ # allowed to be blank; the refresh below will populate the next tick.
36
+ [ -f "$cache_file" ] && cat "$cache_file"
37
+
38
+ now=$(date +%s)
39
+ cache_age=999999
40
+ if [ -f "$cache_file" ]; then
41
+ cache_age=$(( now - $(_substatus_mtime "$cache_file") ))
42
+ [ "$cache_age" -lt 0 ] && cache_age=0
43
+ fi
44
+ [ "$cache_age" -lt 5 ] && return 0
45
+
46
+ # mkdir is the portable atomic lock primitive. Only remove locks that are
47
+ # over a minute old, so a slow but healthy command is never duplicated.
48
+ if [ -d "$lock_dir" ]; then
49
+ lock_age=$(( now - $(_substatus_mtime "$lock_dir") ))
50
+ if [ "$lock_age" -gt 60 ]; then
51
+ rmdir "$lock_dir" 2>/dev/null || rm -rf "$lock_dir" 2>/dev/null
52
+ fi
53
+ fi
54
+
55
+ mkdir "$lock_dir" 2>/dev/null || return 0
56
+
57
+ # Keep the parent statusline fast even when the command takes minutes.
58
+ # The explicit /dev/null stdin makes this process independent of Claude's
59
+ # killed statusline process; the captured payload is piped to the command.
60
+ (
61
+ local tmp_file="$cache_file.$$"
62
+ printf '%s' "${BUDDY_STATUSLINE_INPUT:-}" | sh -c "$command" > "$tmp_file" 2>/dev/null
63
+ mv "$tmp_file" "$cache_file" 2>/dev/null || rm -f "$tmp_file" 2>/dev/null
64
+ rmdir "$lock_dir" 2>/dev/null || rm -rf "$lock_dir" 2>/dev/null
65
+ ) </dev/null > /dev/null 2>/dev/null &
66
+ }