@ramarivera/coding-buddy 0.7.1 → 0.7.3

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.3"
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.3",
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.3",
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,104 +2,73 @@ 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
+ import {
7
+ composeDetailsAndArt,
8
+ displayWidth,
9
+ getDetailsWidth,
10
+ getWidgetWidth,
11
+ stripAnsi,
12
+ WIDGET_MAX_LINES,
13
+ wrapReaction,
14
+ } from "../shared/widget-layout.ts";
15
+
16
+ const RESET = "\x1b[0m";
17
+ const DIM_ITALIC = "\x1b[2;3m";
18
+
19
+ export { displayWidth, WIDGET_MAX_LINES as OMP_WIDGET_MAX_LINES };
5
20
 
6
21
  function trimArt(line: string): string {
7
22
  return line.replace(/\s+$/g, "");
8
23
  }
9
24
 
10
- const REACTION_WIDTH = 44;
11
-
12
- function displayWidth(text: string): number {
13
- let width = 0;
14
- for (const character of text) {
15
- width += /\p{Emoji_Presentation}/u.test(character) ? 2 : 1;
16
- }
17
- return width;
18
- }
19
-
20
- function truncateToWidth(text: string, width: number): string {
21
- let result = "";
22
- 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;
26
- result += character;
27
- currentWidth += characterWidth;
25
+ function getFullArtFrame(companion: Companion, frame: number): string[] {
26
+ const art = getArtFrame(companion.bones.species, companion.bones.eye, frame);
27
+ if (companion.bones.hat !== "none" && !stripAnsi(art[0] ?? "").trim()) {
28
+ art[0] = HAT_ART[companion.bones.hat];
28
29
  }
29
- return result;
30
- }
31
-
32
- function wrapReaction(reaction: string): string[] {
33
- const lines: string[] = [];
34
- let current = "";
35
-
36
- for (const word of reaction.trim().split(/\s+/)) {
37
- if (!word) continue;
38
- if (displayWidth(word) > REACTION_WIDTH) {
39
- if (current) lines.push(current);
40
- lines.push(`${truncateToWidth(word, REACTION_WIDTH - 1)}…`);
41
- current = "";
42
- continue;
43
- }
44
-
45
- const candidate = current ? `${current} ${word}` : word;
46
- if (displayWidth(candidate) > REACTION_WIDTH) {
47
- lines.push(current);
48
- current = word;
49
- } else {
50
- current = candidate;
51
- }
52
- }
53
-
54
- if (current) lines.push(current);
55
- return lines;
56
- }
57
-
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
- ];
30
+ return art.map(trimArt);
66
31
  }
67
32
 
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}`;
72
- }
73
-
74
- export function renderBuddyWidget(
33
+ function renderDetails(
75
34
  companion: Companion,
76
- reaction?: ReactionState | null,
77
- achievements: Achievement[] = [],
35
+ reaction: ReactionState | null | undefined,
36
+ achievements: Achievement[],
37
+ sideWidth: number,
78
38
  ): 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])];
39
+ const color = getRarityColor(companion.bones.rarity);
82
40
  const stars = RARITY_STARS[companion.bones.rarity];
83
41
  const shiny = companion.bones.shiny ? " ✨" : "";
84
42
  const lines = [
85
- `buddy · ${companion.name}`,
86
- `${companion.bones.rarity} ${companion.bones.species} ${stars}${shiny}`,
87
- "",
88
- ...hat,
89
- ...art,
43
+ `${color}${companion.name} ${stars}${shiny}${RESET}`,
44
+ `${companion.bones.rarity} ${companion.bones.species}`,
90
45
  ];
91
46
 
92
47
  if (reaction?.reaction) {
93
- lines.push("", ...renderReactionBubble(reaction.reaction));
48
+ const remainingLines = Math.max(1, WIDGET_MAX_LINES - lines.length);
49
+ lines.push(...wrapReaction(reaction.reaction, sideWidth, remainingLines).map((line) => `${DIM_ITALIC}${line}${RESET}`));
94
50
  }
95
51
 
96
- if (achievements.length > 0) {
97
- lines.push("", ...achievements.map((achievement) => `🏆 ${achievement.name}`));
52
+ for (const achievement of achievements) {
53
+ if (lines.length >= WIDGET_MAX_LINES) break;
54
+ lines.push(`🏆 ${achievement.name}`);
98
55
  }
99
56
 
100
57
  return lines;
101
58
  }
102
59
 
60
+ export function renderBuddyWidget(
61
+ companion: Companion,
62
+ reaction?: ReactionState | null,
63
+ achievements: Achievement[] = [],
64
+ width: number = getWidgetWidth(),
65
+ ): string[] {
66
+ const art = getFullArtFrame(companion, Math.floor(Date.now() / 700));
67
+ const sideWidth = getDetailsWidth(art, width);
68
+ const details = renderDetails(companion, reaction, achievements, sideWidth);
69
+ return composeDetailsAndArt(details, art, width, WIDGET_MAX_LINES);
70
+ }
71
+
103
72
  export function renderBuddyStats(companion: Companion): string[] {
104
73
  return [
105
74
  `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),
@@ -2,45 +2,59 @@ 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
+ import {
7
+ composeDetailsAndArt,
8
+ getDetailsWidth,
9
+ getWidgetWidth,
10
+ WIDGET_MAX_LINES,
11
+ wrapReaction,
12
+ } from "../shared/widget-layout.ts";
13
+
14
+ export const PI_WIDGET_MAX_LINES = WIDGET_MAX_LINES;
15
+
16
+ const RESET = "\x1b[0m";
17
+ const DIM_ITALIC = "\x1b[2;3m";
5
18
 
6
19
  function trimArt(line: string): string {
7
20
  return line.replace(/\s+$/g, "");
8
21
  }
9
22
 
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}`;
15
- }
16
-
17
23
  export function renderBuddyWidget(
18
24
  companion: Companion,
19
25
  reaction?: ReactionState | null,
20
26
  achievements: Achievement[] = [],
27
+ width: number = getWidgetWidth(),
21
28
  ): 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])];
29
+ const art = getFullArtFrame(companion, Math.floor(Date.now() / 700));
30
+ const sideWidth = getDetailsWidth(art, width);
31
+ const color = getRarityColor(companion.bones.rarity);
25
32
  const stars = RARITY_STARS[companion.bones.rarity];
26
33
  const shiny = companion.bones.shiny ? " ✨" : "";
27
- const lines = [
28
- `buddy · ${companion.name}`,
29
- `${companion.bones.rarity} ${companion.bones.species} ${stars}${shiny}`,
30
- "",
31
- ...hat,
32
- ...art,
34
+ const details = [
35
+ `${color}${companion.name} ${stars}${shiny}${RESET}`,
36
+ `${companion.bones.rarity} ${companion.bones.species}`,
33
37
  ];
34
38
 
35
39
  if (reaction?.reaction) {
36
- lines.push("", `“${reaction.reaction}”`);
40
+ const remainingLines = Math.max(1, PI_WIDGET_MAX_LINES - details.length);
41
+ details.push(...wrapReaction(reaction.reaction, sideWidth, remainingLines).map((line) => `${DIM_ITALIC}${line}${RESET}`));
37
42
  }
38
43
 
39
- if (achievements.length > 0) {
40
- lines.push("", ...achievements.map((achievement) => `🏆 ${achievement.name}`));
44
+ for (const achievement of achievements) {
45
+ if (details.length >= PI_WIDGET_MAX_LINES) break;
46
+ details.push(`🏆 ${achievement.name}`);
41
47
  }
42
48
 
43
- return lines;
49
+ return composeDetailsAndArt(details, art, width, PI_WIDGET_MAX_LINES);
50
+ }
51
+
52
+ function getFullArtFrame(companion: Companion, frame: number): string[] {
53
+ const art = getArtFrame(companion.bones.species, companion.bones.eye, frame);
54
+ if (companion.bones.hat !== "none" && !(art[0] ?? "").trim()) {
55
+ art[0] = HAT_ART[companion.bones.hat];
56
+ }
57
+ return art.map(trimArt);
44
58
  }
45
59
 
46
60
  export function renderBuddyStats(companion: Companion): string[] {
package/adapters/pi/ui.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
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 { PiBuddyStorage } from "./storage.ts";
6
6
 
7
7
  type PiBuddyUiContext = ExtensionContext & {
@@ -32,8 +32,7 @@ export class PiBuddyUI {
32
32
  ): void {
33
33
  const currentReaction = reaction ?? null;
34
34
  const muted = this.storage.isMuted();
35
- const status = renderBuddyStatus(companion, muted ? null : currentReaction);
36
- ctx.ui.setStatus("buddy", muted ? `${status} [muted]` : status);
35
+ ctx.ui.setStatus("buddy", undefined);
37
36
  ctx.ui.setWidget(
38
37
  "buddy",
39
38
  renderBuddyWidget(companion, muted ? null : currentReaction, achievements),
@@ -0,0 +1,96 @@
1
+ const ANSI_PATTERN = /\x1b\[[0-?]*[ -/]*[@-~]/g;
2
+ const ART_GAP = " ";
3
+ const DEFAULT_WIDGET_WIDTH = 78;
4
+
5
+ export const WIDGET_MAX_LINES = 10;
6
+
7
+ export function stripAnsi(text: string): string {
8
+ return text.replace(ANSI_PATTERN, "");
9
+ }
10
+
11
+ function characterWidth(character: string, next?: string): number {
12
+ if (character === "\u200d" || /[\uFE00-\uFE0F]/u.test(character)) return 0;
13
+ if (next === "\uFE0F" && /\p{Emoji}/u.test(character)) return 2;
14
+ return /\p{Emoji_Presentation}/u.test(character) ? 2 : 1;
15
+ }
16
+
17
+ export function displayWidth(text: string): number {
18
+ const characters = [...stripAnsi(text)];
19
+ let width = 0;
20
+ for (let index = 0; index < characters.length; index += 1) {
21
+ width += characterWidth(characters[index]!, characters[index + 1]);
22
+ }
23
+ return width;
24
+ }
25
+
26
+ export function truncateToWidth(text: string, width: number): string {
27
+ if (width <= 0) return "";
28
+ let result = "";
29
+ let currentWidth = 0;
30
+ for (const character of [...stripAnsi(text)]) {
31
+ const nextWidth = characterWidth(character);
32
+ if (currentWidth + nextWidth > width) break;
33
+ result += character;
34
+ currentWidth += nextWidth;
35
+ }
36
+ return result;
37
+ }
38
+
39
+ export function wrapReaction(reaction: string, width: number, maxLines: number): string[] {
40
+ const lines: string[] = [];
41
+ let current = "";
42
+
43
+ for (const word of `💬 ${reaction.trim()}`.split(/\s+/)) {
44
+ if (!word) continue;
45
+ if (displayWidth(word) > width) {
46
+ if (current) lines.push(current);
47
+ lines.push(`${truncateToWidth(word, Math.max(1, width - 1))}…`);
48
+ current = "";
49
+ continue;
50
+ }
51
+
52
+ const candidate = current ? `${current} ${word}` : word;
53
+ if (displayWidth(candidate) > width) {
54
+ if (current) lines.push(current);
55
+ current = word;
56
+ } else {
57
+ current = candidate;
58
+ }
59
+ }
60
+
61
+ if (current) lines.push(current);
62
+ if (lines.length <= maxLines) return lines;
63
+
64
+ const bounded = lines.slice(0, maxLines);
65
+ const last = bounded[maxLines - 1] ?? "";
66
+ bounded[maxLines - 1] = `${truncateToWidth(last, Math.max(1, width - 1))}…`;
67
+ return bounded;
68
+ }
69
+
70
+ export function getWidgetWidth(): number {
71
+ const columns = process.stdout.columns;
72
+ return columns && columns > 0 ? Math.max(24, columns - 2) : DEFAULT_WIDGET_WIDTH;
73
+ }
74
+
75
+ export function getDetailsWidth(art: string[], width: number = getWidgetWidth()): number {
76
+ const artWidth = Math.max(...art.map(displayWidth), 0);
77
+ return Math.max(8, Math.max(24, width) - artWidth - ART_GAP.length);
78
+ }
79
+
80
+ export function composeDetailsAndArt(
81
+ details: string[],
82
+ art: string[],
83
+ width: number = getWidgetWidth(),
84
+ maxLines: number = WIDGET_MAX_LINES,
85
+ ): string[] {
86
+ const artWidth = Math.max(...art.map(displayWidth), 0);
87
+ const sideWidth = getDetailsWidth(art, width);
88
+ const rows = Math.max(art.length, details.length);
89
+
90
+ return Array.from({ length: Math.min(maxLines, rows) }, (_, index) => {
91
+ const artLine = art[index] ?? "";
92
+ const detailLine = details[index] ?? "";
93
+ const boundedDetailLine = displayWidth(detailLine) > sideWidth ? truncateToWidth(detailLine, sideWidth) : detailLine;
94
+ return `${boundedDetailLine}${" ".repeat(Math.max(0, sideWidth - displayWidth(boundedDetailLine)))}${ART_GAP}${artLine}${" ".repeat(Math.max(0, artWidth - displayWidth(artLine)))}`;
95
+ });
96
+ }
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.3",
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
+ }