xtrm-tools 0.5.27 → 0.5.28
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/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +1 -0
- package/README.md +1 -0
- package/cli/package.json +1 -1
- package/config/pi/extensions/custom-footer/index.ts +208 -45
- package/package.json +1 -1
- package/plugins/xtrm-tools/.claude-plugin/plugin.json +1 -1
- package/plugins/xtrm-tools/skills/planning/SKILL.md +350 -0
- package/plugins/xtrm-tools/skills/planning/evals/evals.json +19 -0
- package/skills/planning/SKILL.md +350 -0
- package/skills/planning/evals/evals.json +19 -0
- package/config/pi/extensions/plan-mode/README.md +0 -65
- package/config/pi/extensions/plan-mode/index.ts +0 -417
- package/config/pi/extensions/plan-mode/package.json +0 -12
- package/config/pi/extensions/plan-mode/utils.ts +0 -324
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,11 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* XTRM Custom Footer Extension
|
|
3
3
|
*
|
|
4
|
-
* Displays: XTRM brand,
|
|
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
|
|
29
|
-
const CHIP_BG_ACTIVE
|
|
30
|
-
const CHIP_BG_BLOCKED
|
|
31
|
-
const CHIP_FG
|
|
32
|
-
const CHIP_RESET
|
|
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
|
|
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 (
|
|
160
|
+
if (refreshingBeads || Date.now() - beadState.lastFetch < CACHE_TTL) return;
|
|
54
161
|
const cwd = getCwd();
|
|
55
|
-
if (!EventAdapter.isBeadsProject(cwd)) return;
|
|
56
|
-
|
|
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 {
|
|
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 = {
|
|
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 = {
|
|
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
|
-
|
|
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
|
|
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(() =>
|
|
249
|
+
const unsub = footerData.onBranchChange(() => {
|
|
250
|
+
runtimeState.lastFetch = 0;
|
|
251
|
+
tui.requestRender();
|
|
252
|
+
});
|
|
109
253
|
|
|
110
254
|
return {
|
|
111
|
-
dispose() {
|
|
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"
|
|
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,
|
|
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
|
|
274
|
+
const modelStr = `${modelId} ${usageStr}`;
|
|
133
275
|
|
|
134
|
-
const
|
|
135
|
-
const
|
|
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
|
-
|
|
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
|
|
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
|
|
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