xtrm-tools 0.5.27 → 0.5.29

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xtrm-tools",
3
- "version": "0.5.27",
3
+ "version": "0.5.29",
4
4
  "description": "xtrm-tools: dual-runtime workflow enforcement (Claude Code + Pi) — hooks, extensions, skills, and MCP servers",
5
5
  "author": {
6
6
  "name": "jaggers"
package/CHANGELOG.md CHANGED
@@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
18
18
  ### Changed
19
19
  - v0.5.26 docs sync and Pi parity updates: quality gates, beads/session-flow lifecycle, using-xtrm loader parity, and policy-path normalization
20
20
  - Pi installer parity: `xt pi setup` now matches `xt pi install/reload` for extension deployment; managed extensions use sync + auto-discovery and no longer use duplicate `pi install -l` registration
21
+ - Pi custom-footer now tracks Claude statusline parity with richer runtime/git snapshots and a two-line footer layout (metadata + issue row), including pi-dex-safe reapply behavior.
21
22
  ## [0.5.20] - 2026-03-21
22
23
 
23
24
  ### Added
package/README.md CHANGED
@@ -120,6 +120,7 @@ xtrm <command> [options]
120
120
  - `xt pi setup`, `xt pi install`, and `xt pi reload` share the same managed extension sync behavior.
121
121
  - Extensions from `config/pi/extensions/<name>/` are synced to `~/.pi/agent/extensions/<name>/` and loaded by Pi auto-discovery.
122
122
  - Managed extensions are not re-registered with `pi install -l` (prevents duplicate command/flag/shortcut registration conflicts).
123
+ - `custom-footer` now mirrors Claude statusline information density with a two-line parity layout (session metadata + claim/open issue row), while remaining compatible with `pi-dex` footer refresh behavior.
123
124
 
124
125
  ### Flags
125
126
 
package/cli/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xtrm-cli",
3
- "version": "0.5.27",
3
+ "version": "0.5.29",
4
4
  "description": "Claude Code tools installer (skills, hooks, MCP servers)",
5
5
  "main": "./dist/index.js",
6
6
  "type": "module",
@@ -1,11 +1,13 @@
1
1
  /**
2
2
  * XTRM Custom Footer Extension
3
3
  *
4
- * Displays: XTRM brand, Model, Context%, CWD, Git branch, Beads chip
4
+ * Displays: XTRM brand, model/context, host, cwd, git branch/status, beads state.
5
5
  */
6
6
 
7
7
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
8
8
  import { truncateToWidth } from "@mariozechner/pi-tui";
9
+ import { basename, relative } from "node:path";
10
+ import { hostname } from "node:os";
9
11
 
10
12
  import { SubprocessRunner, EventAdapter } from "../core/lib";
11
13
 
@@ -13,25 +15,34 @@ export default function (pi: ExtensionAPI) {
13
15
  interface BeadState {
14
16
  claimId: string | null;
15
17
  shortId: string | null;
18
+ claimTitle: string | null;
16
19
  status: string | null;
17
20
  openCount: number;
18
21
  lastFetch: number;
19
22
  }
20
23
 
24
+ interface RuntimeState {
25
+ host: string;
26
+ displayDir: string;
27
+ branch: string | null;
28
+ gitStatus: string;
29
+ venv: string | null;
30
+ lastFetch: number;
31
+ }
32
+
21
33
  const STATUS_ICONS: Record<string, string> = {
22
34
  open: "○",
23
35
  in_progress: "◐",
24
36
  blocked: "●",
25
37
  closed: "✓",
26
38
  };
39
+
27
40
  // Chip background colours (raw ANSI — theme has no bg() API)
28
- const CHIP_BG_NEUTRAL = "\x1b[48;5;238m"; // dark gray
29
- const CHIP_BG_ACTIVE = "\x1b[48;5;39m"; // blue
30
- const CHIP_BG_BLOCKED = "\x1b[48;5;88m"; // red
31
- const CHIP_FG = "\x1b[38;5;15m"; // white
32
- const CHIP_RESET = "\x1b[0m";
33
- const chip = (text: string, bg = CHIP_BG_NEUTRAL): string =>
34
- `${bg}${CHIP_FG} ${text} ${CHIP_RESET}`;
41
+ const CHIP_BG_NEUTRAL = "\x1b[48;5;238m";
42
+ const CHIP_BG_ACTIVE = "\x1b[48;5;39m";
43
+ const CHIP_BG_BLOCKED = "\x1b[48;5;88m";
44
+ const CHIP_FG = "\x1b[38;5;15m";
45
+ const CHIP_RESET = "\x1b[0m";
35
46
 
36
47
  const STATUS_BG: Record<string, string> = {
37
48
  open: CHIP_BG_NEUTRAL,
@@ -39,35 +50,144 @@ export default function (pi: ExtensionAPI) {
39
50
  blocked: CHIP_BG_BLOCKED,
40
51
  };
41
52
 
53
+ const chip = (text: string, bg = CHIP_BG_NEUTRAL): string => `${bg}${CHIP_FG} ${text} ${CHIP_RESET}`;
54
+
42
55
  let capturedCtx: any = null;
43
- let sessionId: string = "";
44
- let beadState: BeadState = { claimId: null, shortId: null, status: null, openCount: 0, lastFetch: 0 };
45
- let refreshing = false;
56
+ let sessionId = "";
46
57
  let requestRender: (() => void) | null = null;
58
+
47
59
  const CACHE_TTL = 5000;
60
+ let refreshingBeads = false;
61
+ let refreshingRuntime = false;
62
+
63
+ let beadState: BeadState = {
64
+ claimId: null,
65
+ shortId: null,
66
+ claimTitle: null,
67
+ status: null,
68
+ openCount: 0,
69
+ lastFetch: 0,
70
+ };
71
+
72
+ let runtimeState: RuntimeState = {
73
+ host: hostname().split(".")[0] || "host",
74
+ displayDir: process.cwd(),
75
+ branch: null,
76
+ gitStatus: "",
77
+ venv: process.env.VIRTUAL_ENV ? basename(process.env.VIRTUAL_ENV) : null,
78
+ lastFetch: 0,
79
+ };
48
80
 
49
81
  const getCwd = () => capturedCtx?.cwd || process.cwd();
50
82
  const getShortId = (id: string) => id.split("-").pop() ?? id;
51
83
 
84
+ const parseGitFlags = (porcelain: string): string => {
85
+ let modified = false;
86
+ let staged = false;
87
+ let deleted = false;
88
+ for (const line of porcelain.split("\n").filter(Boolean)) {
89
+ if (/^ M|^AM|^MM/.test(line)) modified = true;
90
+ if (/^A |^M /.test(line)) staged = true;
91
+ if (/^ D|^D /.test(line)) deleted = true;
92
+ }
93
+ return `${modified ? "*" : ""}${staged ? "+" : ""}${deleted ? "-" : ""}`;
94
+ };
95
+
96
+ const refreshRuntimeState = async () => {
97
+ if (refreshingRuntime || Date.now() - runtimeState.lastFetch < CACHE_TTL) return;
98
+ refreshingRuntime = true;
99
+ const cwd = getCwd();
100
+ try {
101
+ const host = hostname().split(".")[0] || "host";
102
+ const venv = process.env.VIRTUAL_ENV ? basename(process.env.VIRTUAL_ENV) : null;
103
+ const rootResult = await SubprocessRunner.run("git", ["rev-parse", "--show-toplevel"], { cwd });
104
+ const repoRoot = rootResult.code === 0 ? rootResult.stdout.trim() : null;
105
+
106
+ const displayDir = repoRoot
107
+ ? (() => {
108
+ const relPath = relative(repoRoot, cwd) || ".";
109
+ return relPath === "." ? basename(repoRoot) : `${basename(repoRoot)}/${relPath}`;
110
+ })()
111
+ : (() => {
112
+ const parts = cwd.split("/");
113
+ return parts.length > 2 ? parts.slice(-2).join("/") : cwd;
114
+ })();
115
+
116
+ let branch: string | null = null;
117
+ let gitStatus = "";
118
+ if (repoRoot) {
119
+ const branchResult = await SubprocessRunner.run("git", ["branch", "--show-current"], { cwd });
120
+ branch = branchResult.code === 0 ? branchResult.stdout.trim() || null : null;
121
+
122
+ const porcelainResult = await SubprocessRunner.run("git", ["--no-optional-locks", "status", "--porcelain"], { cwd });
123
+ const baseFlags = porcelainResult.code === 0 ? parseGitFlags(porcelainResult.stdout) : "";
124
+
125
+ let upstreamFlags = "";
126
+ const abResult = await SubprocessRunner.run(
127
+ "git",
128
+ ["--no-optional-locks", "rev-list", "--left-right", "--count", "@{upstream}...HEAD"],
129
+ { cwd },
130
+ );
131
+ if (abResult.code === 0) {
132
+ const [behindRaw, aheadRaw] = abResult.stdout.trim().split(/\s+/);
133
+ const behind = Number(behindRaw || 0);
134
+ const ahead = Number(aheadRaw || 0);
135
+ if (ahead > 0 && behind > 0) upstreamFlags = "↕";
136
+ else if (ahead > 0) upstreamFlags = "↑";
137
+ else if (behind > 0) upstreamFlags = "↓";
138
+ }
139
+
140
+ gitStatus = `${baseFlags}${upstreamFlags}`;
141
+ }
142
+
143
+ runtimeState = {
144
+ host,
145
+ displayDir,
146
+ branch,
147
+ gitStatus,
148
+ venv,
149
+ lastFetch: Date.now(),
150
+ };
151
+ requestRender?.();
152
+ } catch {
153
+ // Fail soft — keep last known runtime state.
154
+ } finally {
155
+ refreshingRuntime = false;
156
+ }
157
+ };
158
+
52
159
  const refreshBeadState = async () => {
53
- if (refreshing || Date.now() - beadState.lastFetch < CACHE_TTL) return;
160
+ if (refreshingBeads || Date.now() - beadState.lastFetch < CACHE_TTL) return;
54
161
  const cwd = getCwd();
55
- if (!EventAdapter.isBeadsProject(cwd)) return;
56
- if (!sessionId) return;
57
- refreshing = true;
162
+ if (!EventAdapter.isBeadsProject(cwd) || !sessionId) return;
163
+ refreshingBeads = true;
58
164
  try {
59
165
  const claimResult = await SubprocessRunner.run("bd", ["kv", "get", `claimed:${sessionId}`], { cwd });
60
166
  const claimId = claimResult.code === 0 ? claimResult.stdout.trim() || null : null;
61
167
 
62
168
  let status: string | null = null;
169
+ let claimTitle: string | null = null;
63
170
  if (claimId) {
64
171
  const showResult = await SubprocessRunner.run("bd", ["show", claimId, "--json"], { cwd });
65
172
  if (showResult.code === 0) {
66
- try { status = JSON.parse(showResult.stdout)[0]?.status ?? null; } catch {}
173
+ try {
174
+ const issue = JSON.parse(showResult.stdout)?.[0];
175
+ status = issue?.status ?? null;
176
+ claimTitle = issue?.title ?? null;
177
+ } catch {
178
+ // keep nulls
179
+ }
67
180
  }
68
181
  if (status === "closed") {
69
182
  await SubprocessRunner.run("bd", ["kv", "clear", `claimed:${sessionId}`], { cwd });
70
- beadState = { claimId: null, shortId: null, status: null, openCount: beadState.openCount, lastFetch: Date.now() };
183
+ beadState = {
184
+ claimId: null,
185
+ shortId: null,
186
+ claimTitle: null,
187
+ status: null,
188
+ openCount: beadState.openCount,
189
+ lastFetch: Date.now(),
190
+ };
71
191
  requestRender?.();
72
192
  return;
73
193
  }
@@ -80,10 +200,20 @@ export default function (pi: ExtensionAPI) {
80
200
  if (m) openCount = parseInt(m[1], 10);
81
201
  }
82
202
 
83
- beadState = { claimId, shortId: claimId ? getShortId(claimId) : null, status, openCount, lastFetch: Date.now() };
203
+ beadState = {
204
+ claimId,
205
+ shortId: claimId ? getShortId(claimId) : null,
206
+ claimTitle,
207
+ status,
208
+ openCount,
209
+ lastFetch: Date.now(),
210
+ };
84
211
  requestRender?.();
85
- } catch {}
86
- finally { refreshing = false; }
212
+ } catch {
213
+ // Fail soft keep last known beads state.
214
+ } finally {
215
+ refreshingBeads = false;
216
+ }
87
217
  };
88
218
 
89
219
  const buildBeadChip = (): string => {
@@ -93,10 +223,21 @@ export default function (pi: ExtensionAPI) {
93
223
  const bg = STATUS_BG[status] ?? CHIP_BG_NEUTRAL;
94
224
  return chip(`bd:${shortId}${icon}`, bg);
95
225
  }
226
+ if (openCount > 0) return chip(`bd:${openCount}${STATUS_ICONS.open}`);
227
+ return "";
228
+ };
229
+
230
+ const buildIssueLine = (width: number, theme: any): string => {
231
+ const { shortId, claimTitle, openCount } = beadState;
232
+ if (shortId && claimTitle) {
233
+ const prefix = `◐ ${shortId} `;
234
+ const title = theme.fg("muted", claimTitle);
235
+ return truncateToWidth(`${prefix}${title}`, width);
236
+ }
96
237
  if (openCount > 0) {
97
- return chip(`bd:${openCount}${STATUS_ICONS.open}`);
238
+ return truncateToWidth(`○ ${openCount} open`, width);
98
239
  }
99
- return "";
240
+ return truncateToWidth("○ no open issues", width);
100
241
  };
101
242
 
102
243
  let footerReapplyTimer: ReturnType<typeof setTimeout> | null = null;
@@ -105,41 +246,54 @@ export default function (pi: ExtensionAPI) {
105
246
  capturedCtx = ctx;
106
247
  ctx.ui.setFooter((tui, theme, footerData) => {
107
248
  requestRender = () => tui.requestRender();
108
- const unsub = footerData.onBranchChange(() => tui.requestRender());
249
+ const unsub = footerData.onBranchChange(() => {
250
+ runtimeState.lastFetch = 0;
251
+ tui.requestRender();
252
+ });
109
253
 
110
254
  return {
111
- dispose() { unsub(); requestRender = null; },
255
+ dispose() {
256
+ unsub();
257
+ requestRender = null;
258
+ },
112
259
  invalidate() {},
113
260
  render(width: number): string[] {
261
+ refreshRuntimeState().catch(() => {});
114
262
  refreshBeadState().catch(() => {});
115
263
 
116
- const BOLD = "\x1b[1m", BOLD_OFF = "\x1b[22m";
264
+ const BOLD = "\x1b[1m";
265
+ const BOLD_OFF = "\x1b[22m";
117
266
  const brand = `${BOLD}${theme.fg("accent", "XTRM")}${BOLD_OFF}`;
118
267
 
119
268
  const usage = ctx.getContextUsage();
120
269
  const pct = usage?.percent ?? 0;
121
270
  const pctColor = pct > 75 ? "error" : pct > 50 ? "warning" : "success";
122
- const usageStr = theme.fg(pctColor, `${pct.toFixed(0)}%`);
123
-
124
- const parts = process.cwd().split("/");
125
- const short = parts.length > 2 ? parts.slice(-2).join("/") : process.cwd();
126
- const cwdStr = theme.fg("muted", `⌂ ${short}`);
127
-
128
- const branch = footerData.getGitBranch();
129
- const branchStr = branch ? theme.fg("accent", `⎇ ${branch}`) : "";
271
+ const usageStr = theme.fg(pctColor, `[${pct.toFixed(0)}%]`);
130
272
 
131
273
  const modelId = ctx.model?.id || "no-model";
132
- const modelChip = chip(modelId);
274
+ const modelStr = `${modelId} ${usageStr}`;
133
275
 
134
- const sep = " ";
135
- const brandModel = `${brand} ${modelChip}`;
276
+ const branchFromFooter = footerData.getGitBranch();
277
+ const branch = runtimeState.branch || branchFromFooter;
278
+ const branchWithStatus = branch
279
+ ? runtimeState.gitStatus
280
+ ? `${branch} (${runtimeState.gitStatus})`
281
+ : branch
282
+ : "";
283
+ const branchStr = branchWithStatus ? theme.fg("muted", branchWithStatus) : "";
284
+ const hostStr = theme.fg("muted", runtimeState.host);
285
+ const cwdStr = `${BOLD}${runtimeState.displayDir}${BOLD_OFF}`;
286
+ const venvStr = runtimeState.venv ? theme.fg("muted", `(${runtimeState.venv})`) : "";
136
287
  const beadChip = buildBeadChip();
137
- const leftParts = [brandModel, usageStr];
138
- if (beadChip) leftParts.push(beadChip);
139
- leftParts.push(cwdStr);
140
- if (branchStr) leftParts.push(branchStr);
141
288
 
142
- return [truncateToWidth(leftParts.join(sep), width)];
289
+ const line1Parts = [brand, modelStr, hostStr, cwdStr];
290
+ if (branchStr) line1Parts.push(branchStr);
291
+ if (venvStr) line1Parts.push(venvStr);
292
+ if (beadChip) line1Parts.push(beadChip);
293
+
294
+ const line1 = truncateToWidth(line1Parts.join(" "), width);
295
+ const line2 = buildIssueLine(width, theme);
296
+ return [line1, line2];
143
297
  },
144
298
  };
145
299
  });
@@ -155,22 +309,25 @@ export default function (pi: ExtensionAPI) {
155
309
 
156
310
  pi.on("session_start", async (_event, ctx) => {
157
311
  capturedCtx = ctx;
158
- // Get session ID from sessionManager/context (prefer UUID, consistent with hooks)
159
312
  sessionId = ctx.sessionManager?.getSessionId?.() ?? ctx.sessionId ?? ctx.session_id ?? process.pid.toString();
313
+ runtimeState.lastFetch = 0;
314
+ beadState.lastFetch = 0;
160
315
  applyCustomFooter(ctx);
161
- // pi-dex reapplies footer on session start with setTimeout(0); reclaim after that pass.
162
316
  scheduleFooterReapply(ctx);
163
317
  });
164
318
 
165
319
  pi.on("session_switch", async (_event, ctx) => {
320
+ runtimeState.lastFetch = 0;
166
321
  scheduleFooterReapply(ctx);
167
322
  });
168
323
 
169
324
  pi.on("session_fork", async (_event, ctx) => {
325
+ runtimeState.lastFetch = 0;
170
326
  scheduleFooterReapply(ctx);
171
327
  });
172
328
 
173
329
  pi.on("model_select", async (_event, ctx) => {
330
+ runtimeState.lastFetch = 0;
174
331
  scheduleFooterReapply(ctx);
175
332
  });
176
333
 
@@ -181,13 +338,19 @@ export default function (pi: ExtensionAPI) {
181
338
  }
182
339
  });
183
340
 
184
- // Bust the bead cache immediately after any bd write
341
+ // Bust caches immediately after relevant writes
185
342
  pi.on("tool_result", async (event: any) => {
186
343
  const cmd = event?.input?.command;
187
- if (cmd && /\bbd\s+(close|update|create|claim)\b/.test(cmd)) {
344
+ if (!cmd) return undefined;
345
+
346
+ if (/\bbd\s+(close|update|create|claim)\b/.test(cmd)) {
188
347
  beadState.lastFetch = 0;
189
348
  setTimeout(() => refreshBeadState().catch(() => {}), 200);
190
349
  }
350
+ if (/\bgit\s+/.test(cmd)) {
351
+ runtimeState.lastFetch = 0;
352
+ setTimeout(() => refreshRuntimeState().catch(() => {}), 200);
353
+ }
191
354
  return undefined;
192
355
  });
193
356
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xtrm-tools",
3
- "version": "0.5.27",
3
+ "version": "0.5.29",
4
4
  "description": "Claude Code tools installer (skills, hooks, MCP servers)",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xtrm-tools",
3
- "version": "0.5.27",
3
+ "version": "0.5.29",
4
4
  "description": "xtrm-tools: dual-runtime workflow enforcement (Claude Code + Pi) — hooks, extensions, skills, and MCP servers",
5
5
  "author": {
6
6
  "name": "jaggers"