@ramarivera/coding-buddy 0.7.1 → 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.1"
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.1",
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.1",
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"
@@ -2,49 +2,68 @@ 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";
5
6
 
6
- function trimArt(line: string): string {
7
- return line.replace(/\s+$/g, "");
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, "");
8
17
  }
9
18
 
10
- const REACTION_WIDTH = 44;
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
+ }
11
24
 
12
- function displayWidth(text: string): number {
25
+ export function displayWidth(text: string): number {
26
+ const characters = [...stripAnsi(text)];
13
27
  let width = 0;
14
- for (const character of text) {
15
- width += /\p{Emoji_Presentation}/u.test(character) ? 2 : 1;
28
+ for (let index = 0; index < characters.length; index += 1) {
29
+ width += characterWidth(characters[index]!, characters[index + 1]);
16
30
  }
17
31
  return width;
18
32
  }
19
33
 
34
+ function trimArt(line: string): string {
35
+ return line.replace(/\s+$/g, "");
36
+ }
37
+
20
38
  function truncateToWidth(text: string, width: number): string {
39
+ if (width <= 0) return "";
21
40
  let result = "";
22
41
  let currentWidth = 0;
23
- for (const character of text) {
24
- const characterWidth = /\p{Emoji_Presentation}/u.test(character) ? 2 : 1;
25
- if (currentWidth + characterWidth > width) break;
42
+ for (const character of [...stripAnsi(text)]) {
43
+ const nextWidth = characterWidth(character);
44
+ if (currentWidth + nextWidth > width) break;
26
45
  result += character;
27
- currentWidth += characterWidth;
46
+ currentWidth += nextWidth;
28
47
  }
29
48
  return result;
30
49
  }
31
50
 
32
- function wrapReaction(reaction: string): string[] {
51
+ function wrapReaction(reaction: string, width: number, maxLines: number): string[] {
33
52
  const lines: string[] = [];
34
53
  let current = "";
35
54
 
36
- for (const word of reaction.trim().split(/\s+/)) {
55
+ for (const word of `💬 ${reaction.trim()}`.split(/\s+/)) {
37
56
  if (!word) continue;
38
- if (displayWidth(word) > REACTION_WIDTH) {
57
+ if (displayWidth(word) > width) {
39
58
  if (current) lines.push(current);
40
- lines.push(`${truncateToWidth(word, REACTION_WIDTH - 1)}…`);
59
+ lines.push(`${truncateToWidth(word, Math.max(1, width - 1))}…`);
41
60
  current = "";
42
61
  continue;
43
62
  }
44
63
 
45
64
  const candidate = current ? `${current} ${word}` : word;
46
- if (displayWidth(candidate) > REACTION_WIDTH) {
47
- lines.push(current);
65
+ if (displayWidth(candidate) > width) {
66
+ if (current) lines.push(current);
48
67
  current = word;
49
68
  } else {
50
69
  current = candidate;
@@ -52,54 +71,74 @@ function wrapReaction(reaction: string): string[] {
52
71
  }
53
72
 
54
73
  if (current) lines.push(current);
55
- return lines;
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;
56
80
  }
57
81
 
58
- function renderReactionBubble(reaction: string): string[] {
59
- const lines = wrapReaction(`💬 ${reaction}`);
60
- const border = `╭${"─".repeat(REACTION_WIDTH + 2)}╮`;
61
- return [
62
- border,
63
- ...lines.map((line) => `│ ${line}${" ".repeat(Math.max(0, REACTION_WIDTH - displayWidth(line)))} │`),
64
- `╰${"─".repeat(REACTION_WIDTH + 2)}╯`,
65
- ];
82
+ function getWidgetWidth(): number {
83
+ const columns = process.stdout.columns;
84
+ return columns && columns > 0 ? Math.max(24, columns - 2) : DEFAULT_WIDGET_WIDTH;
66
85
  }
67
86
 
68
- export function renderBuddyStatus(companion: Companion): string {
69
- const shiny = companion.bones.shiny ? " ✨" : "";
70
- const stars = RARITY_STARS[companion.bones.rarity];
71
- return `${trimArt(getArtFrame(companion.bones.species, companion.bones.eye, Date.now())[0])} ${companion.name} ${stars}${shiny}`;
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);
72
93
  }
73
94
 
74
- export function renderBuddyWidget(
95
+ function renderDetails(
75
96
  companion: Companion,
76
- reaction?: ReactionState | null,
77
- achievements: Achievement[] = [],
97
+ reaction: ReactionState | null | undefined,
98
+ achievements: Achievement[],
99
+ sideWidth: number,
78
100
  ): string[] {
79
- const frame = Math.floor(Date.now() / 700);
80
- const art = getArtFrame(companion.bones.species, companion.bones.eye, frame).map(trimArt);
81
- const hat = companion.bones.hat === "none" ? [] : [trimArt(HAT_ART[companion.bones.hat])];
101
+ const color = getRarityColor(companion.bones.rarity);
82
102
  const stars = RARITY_STARS[companion.bones.rarity];
83
103
  const shiny = companion.bones.shiny ? " ✨" : "";
84
104
  const lines = [
85
- `buddy · ${companion.name}`,
86
- `${companion.bones.rarity} ${companion.bones.species} ${stars}${shiny}`,
87
- "",
88
- ...hat,
89
- ...art,
105
+ `${color}${companion.name} ${stars}${shiny}${RESET}`,
106
+ `${companion.bones.rarity} ${companion.bones.species}`,
90
107
  ];
91
108
 
92
109
  if (reaction?.reaction) {
93
- lines.push("", ...renderReactionBubble(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}`));
94
112
  }
95
113
 
96
- if (achievements.length > 0) {
97
- 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}`);
98
117
  }
99
118
 
100
119
  return lines;
101
120
  }
102
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
+
103
142
  export function renderBuddyStats(companion: Companion): string[] {
104
143
  return [
105
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);
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";
@@ -116,8 +118,8 @@ function saveSettings(settings: Record<string, any>) {
116
118
 
117
119
  // ─── Step 1: Register MCP server (in ~/.claude.json) ────────────────────────
118
120
 
119
- function installMcp() {
120
- const serverPath = join(PROJECT_ROOT, "server", "index.ts");
121
+ function installMcp(appDir: string) {
122
+ const serverPath = stableRuntimePaths(appDir).mcpServer;
121
123
 
122
124
  let claudeJson: Record<string, any> = {};
123
125
  try {
@@ -129,7 +131,7 @@ function installMcp() {
129
131
  claudeJson.mcpServers["claude-buddy"] = {
130
132
  command: "bun",
131
133
  args: [toUnixPath(serverPath)],
132
- cwd: toUnixPath(PROJECT_ROOT),
134
+ cwd: toUnixPath(appDir),
133
135
  };
134
136
 
135
137
  writeFileSync(CLAUDE_JSON_PATH, JSON.stringify(claudeJson, null, 2));
@@ -147,8 +149,8 @@ function installSkill() {
147
149
 
148
150
  // ─── Step 3: Configure status line (with animation refresh) ─────────────────
149
151
 
150
- function installStatusLine(settings: Record<string, any>) {
151
- const statusScript = join(PROJECT_ROOT, "statusline", "buddy-status.sh");
152
+ function installStatusLine(settings: Record<string, any>, appDir: string) {
153
+ const statusScript = stableRuntimePaths(appDir).statusline;
152
154
 
153
155
  settings.statusLine = {
154
156
  type: "command",
@@ -181,13 +183,14 @@ function stripLegacyPopupHooks(settings: Record<string, any>) {
181
183
 
182
184
  // ─── Step 4: Register hooks ─────────────────────────────────────────────────
183
185
 
184
- function installHooks(settings: Record<string, any>) {
185
- const reactHook = join(PROJECT_ROOT, "hooks", "react.sh");
186
- const fileTypeHook = join(PROJECT_ROOT, "hooks", "file-type-react.sh");
187
- const commentHook = join(PROJECT_ROOT, "hooks", "buddy-comment.sh");
188
- const suggestHook = join(PROJECT_ROOT, "hooks", "suggest.sh");
189
- const nameHook = join(PROJECT_ROOT, "hooks", "name-react.sh");
190
- 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");
191
194
  const commandHook = (command: string) => ({
192
195
  type: "command",
193
196
  command: toUnixPath(command),
@@ -239,6 +242,15 @@ function installHooks(settings: Record<string, any>) {
239
242
  ok("Hooks registered: PostToolUse (Bash + Write/Edit) + Stop (comment + suggest) + UserPromptSubmit (name + mood)");
240
243
  }
241
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
+
242
254
  // ─── Step 5: Ensure MCP tools are allowed ───────────────────────────────────
243
255
 
244
256
  function ensurePermissions(settings: Record<string, any>) {
@@ -301,14 +313,15 @@ console.log("");
301
313
  info("Installing coding-buddy...\n");
302
314
 
303
315
  const settings = loadSettings();
316
+ const appDir = installRuntimeApp();
304
317
 
305
- installMcp();
318
+ installMcp(appDir);
306
319
  installSkill();
307
320
 
308
321
  stripLegacyPopupHooks(settings);
309
- installStatusLine(settings);
322
+ installStatusLine(settings, appDir);
310
323
 
311
- installHooks(settings);
324
+ installHooks(settings, appDir);
312
325
  ensurePermissions(settings);
313
326
  saveSettings(settings);
314
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ramarivera/coding-buddy",
3
- "version": "0.7.1",
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": {
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"
@@ -39,7 +40,7 @@ ACHIEVEMENT=$(jq -r '.achievement // ""' "$STATE" 2>/dev/null)
39
40
  LEVEL=$(jq -r '.level // 1' "$STATE" 2>/dev/null)
40
41
  MOOD=$(jq -r '.mood // "focused"' "$STATE" 2>/dev/null)
41
42
 
42
- cat > /dev/null # drain stdin
43
+ BUDDY_STATUSLINE_INPUT=$(cat)
43
44
 
44
45
  # ─── Animation: pick current frame from server-rendered frames ──────────────
45
46
  NOW=${BUDDY_FAKE_NOW:-$(date +%s)}
@@ -402,4 +403,8 @@ for (( i=0; i<MAX_LINES; i++ )); do
402
403
  fi
403
404
  done
404
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
+
405
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
+ }