xtrm-tools 2.1.10 → 2.1.11

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/cli/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xtrm-cli",
3
- "version": "2.1.10",
3
+ "version": "2.1.11",
4
4
  "description": "Claude Code tools installer (skills, hooks, MCP servers)",
5
5
  "main": "./dist/index.js",
6
6
  "type": "module",
@@ -0,0 +1,14 @@
1
+ {
2
+ "zai": {
3
+ "type": "api_key",
4
+ "key": "{{ZAI_API_KEY}}"
5
+ },
6
+ "dashscope": {
7
+ "type": "api_key",
8
+ "key": "{{DASHSCOPE_API_KEY}}"
9
+ },
10
+ "anthropic": {},
11
+ "google-gemini-cli": {},
12
+ "qwen-cli": {},
13
+ "google-antigravity": {}
14
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * oh-pi Auto Session Name Extension
3
+ *
4
+ * Automatically names sessions based on the first user message.
5
+ */
6
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
7
+
8
+ export default function (pi: ExtensionAPI) {
9
+ let named = false;
10
+
11
+ pi.on("session_start", async (_event, ctx) => {
12
+ named = !!pi.getSessionName();
13
+ });
14
+
15
+ pi.on("agent_end", async (event) => {
16
+ if (named) return;
17
+ const userMsg = event.messages.find((m) => m.role === "user");
18
+ if (!userMsg) return;
19
+ const text = typeof userMsg.content === "string"
20
+ ? userMsg.content
21
+ : userMsg.content.filter((b) => b.type === "text").map((b) => (b as { text: string }).text).join(" ");
22
+ if (!text) return;
23
+ const name = text.slice(0, 60).replace(/\n/g, " ").trim();
24
+ if (name) {
25
+ pi.setSessionName(name);
26
+ named = true;
27
+ }
28
+ });
29
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * oh-pi Auto Update — check for new oh-pi version on session start
3
+ */
4
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
5
+ import { execSync } from "node:child_process";
6
+ import { readFileSync, writeFileSync, existsSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { homedir } from "node:os";
9
+
10
+ const CHECK_INTERVAL = 24 * 60 * 60 * 1000; // 24h
11
+ const STAMP_FILE = join(homedir(), ".pi", "agent", ".update-check");
12
+
13
+ function readStamp(): number {
14
+ try { return Number(readFileSync(STAMP_FILE, "utf8").trim()) || 0; } catch { return 0; }
15
+ }
16
+
17
+ function writeStamp() {
18
+ try { writeFileSync(STAMP_FILE, String(Date.now())); } catch {}
19
+ }
20
+
21
+ function getLatestVersion(): string | null {
22
+ try {
23
+ return execSync("npm view oh-pi version", { encoding: "utf8", timeout: 8000 }).trim();
24
+ } catch { return null; }
25
+ }
26
+
27
+ function getCurrentVersion(): string | null {
28
+ // Read from the installed package.json
29
+ try {
30
+ const pkgPath = join(__dirname, "..", "..", "package.json");
31
+ if (existsSync(pkgPath)) {
32
+ return JSON.parse(readFileSync(pkgPath, "utf8")).version;
33
+ }
34
+ } catch {}
35
+ // Fallback: npm list
36
+ try {
37
+ const out = JSON.parse(execSync("npm list -g oh-pi --json --depth=0", { encoding: "utf8", timeout: 8000 }));
38
+ return out.dependencies?.["oh-pi"]?.version ?? null;
39
+ } catch { return null; }
40
+ }
41
+
42
+ export function isNewer(latest: string, current: string): boolean {
43
+ const a = latest.split(".").map(Number);
44
+ const b = current.split(".").map(Number);
45
+ for (let i = 0; i < 3; i++) {
46
+ if ((a[i] ?? 0) > (b[i] ?? 0)) return true;
47
+ if ((a[i] ?? 0) < (b[i] ?? 0)) return false;
48
+ }
49
+ return false;
50
+ }
51
+
52
+ export default function (pi: ExtensionAPI) {
53
+ pi.on("session_start", async (_event, ctx) => {
54
+ // Non-blocking: run check in background
55
+ setTimeout(async () => {
56
+ try {
57
+ if (Date.now() - readStamp() < CHECK_INTERVAL) return;
58
+ writeStamp();
59
+
60
+ const current = getCurrentVersion();
61
+ const latest = getLatestVersion();
62
+ if (!current || !latest || !isNewer(latest, current)) return;
63
+
64
+ const msg = `oh-pi ${latest} available (current: ${current}). Run: npx oh-pi@latest`;
65
+ if (ctx.hasUI) {
66
+ ctx.ui.toast?.(msg) ?? console.log(`\n💡 ${msg}\n`);
67
+ }
68
+ } catch {}
69
+ }, 2000);
70
+ });
71
+ }
@@ -0,0 +1,230 @@
1
+ /**
2
+ * oh-pi Background Process Extension
3
+ *
4
+ * 任何 bash 命令超时未完成时,自动送到后台执行。
5
+ * 进程完成后自动通过 sendMessage 通知 LLM,无需轮询。
6
+ * 提供 bg_status 工具让 LLM 查看/停止后台进程。
7
+ */
8
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
9
+ import { Type } from "@sinclair/typebox";
10
+ import { StringEnum } from "@mariozechner/pi-ai";
11
+ import { spawn, execSync } from "node:child_process";
12
+ import { writeFileSync, readFileSync, appendFileSync, existsSync } from "node:fs";
13
+
14
+ /** 超时阈值(毫秒),超过此时间自动后台化 */
15
+ const BG_TIMEOUT_MS = 10_000;
16
+
17
+ interface BgProcess {
18
+ pid: number;
19
+ command: string;
20
+ logFile: string;
21
+ startedAt: number;
22
+ finished: boolean;
23
+ exitCode: number | null;
24
+ }
25
+
26
+ export default function (pi: ExtensionAPI) {
27
+ const bgProcesses = new Map<number, BgProcess>();
28
+
29
+ // 覆盖内置 bash 工具
30
+ pi.registerTool({
31
+ name: "bash",
32
+ label: "Bash",
33
+ description: `Execute a bash command. Output is truncated to 2000 lines or 50KB. If a command runs longer than ${BG_TIMEOUT_MS / 1000}s, it is automatically backgrounded and you get the PID + log file path. Use the bg_status tool to check on backgrounded processes.`,
34
+ parameters: Type.Object({
35
+ command: Type.String({ description: "Bash command to execute" }),
36
+ timeout: Type.Optional(Type.Number({ description: "Timeout in seconds (optional)" })),
37
+ }),
38
+ async execute(toolCallId, params, signal) {
39
+ const { command } = params;
40
+ const userTimeout = params.timeout ? params.timeout * 1000 : undefined;
41
+ const effectiveTimeout = userTimeout ?? BG_TIMEOUT_MS;
42
+
43
+ return new Promise((resolve) => {
44
+ let stdout = "";
45
+ let stderr = "";
46
+ let settled = false;
47
+ let backgrounded = false;
48
+
49
+ const child = spawn("bash", ["-c", command], {
50
+ cwd: process.cwd(),
51
+ env: { ...process.env },
52
+ stdio: ["ignore", "pipe", "pipe"],
53
+ });
54
+
55
+ child.stdout?.on("data", (d: Buffer) => {
56
+ const chunk = d.toString();
57
+ stdout += chunk;
58
+ // 后台化后追加写入日志
59
+ if (backgrounded) {
60
+ try { appendFileSync(bgProcesses.get(child.pid!)?.logFile ?? "", chunk); } catch {}
61
+ }
62
+ });
63
+ child.stderr?.on("data", (d: Buffer) => {
64
+ const chunk = d.toString();
65
+ stderr += chunk;
66
+ if (backgrounded) {
67
+ try { appendFileSync(bgProcesses.get(child.pid!)?.logFile ?? "", chunk); } catch {}
68
+ }
69
+ });
70
+
71
+ // 超时处理:保持管道,标记为后台
72
+ const timer = setTimeout(() => {
73
+ if (settled) return;
74
+ settled = true;
75
+ backgrounded = true;
76
+
77
+ child.unref();
78
+
79
+ const logFile = `/tmp/oh-pi-bg-${Date.now()}.log`;
80
+ const pid = child.pid!;
81
+
82
+ // 把已有输出写入日志
83
+ writeFileSync(logFile, stdout + stderr);
84
+
85
+ const proc: BgProcess = { pid, command, logFile, startedAt: Date.now(), finished: false, exitCode: null };
86
+ bgProcesses.set(pid, proc);
87
+
88
+ // 监听完成事件,自动通知 LLM
89
+ child.on("close", (code) => {
90
+ proc.finished = true;
91
+ proc.exitCode = code;
92
+ const tail = (stdout + stderr).slice(-3000);
93
+ const truncated = (stdout + stderr).length > 3000 ? "[...truncated]\n" + tail : tail;
94
+ // 最终输出写入日志
95
+ try { writeFileSync(logFile, stdout + stderr); } catch {}
96
+
97
+ pi.sendMessage({
98
+ content: `[BG_PROCESS_DONE] PID ${pid} finished (exit ${code ?? "?"})\nCommand: ${command}\n\nOutput (last 3000 chars):\n${truncated}`,
99
+ display: true,
100
+ triggerTurn: true,
101
+ deliverAs: "followUp",
102
+ });
103
+ });
104
+
105
+ const preview = (stdout + stderr).slice(0, 500);
106
+ const text = `Command still running after ${effectiveTimeout / 1000}s, moved to background.\nPID: ${pid}\nLog: ${logFile}\nStop: kill ${pid}\n\nOutput so far:\n${preview}\n\n⏳ You will be notified automatically when it finishes. No need to poll.`;
107
+
108
+ resolve({
109
+ content: [{ type: "text", text }],
110
+ details: {},
111
+ });
112
+ }, effectiveTimeout);
113
+
114
+ // 正常结束(超时前)
115
+ child.on("close", (code) => {
116
+ if (settled) return;
117
+ settled = true;
118
+ clearTimeout(timer);
119
+
120
+ const output = (stdout + stderr).trim();
121
+ const exitInfo = code !== 0 ? `\n[Exit code: ${code}]` : "";
122
+
123
+ resolve({
124
+ content: [{ type: "text", text: output + exitInfo }],
125
+ details: {},
126
+ });
127
+ });
128
+
129
+ child.on("error", (err) => {
130
+ if (settled) return;
131
+ settled = true;
132
+ clearTimeout(timer);
133
+
134
+ resolve({
135
+ content: [{ type: "text", text: `Error: ${err.message}` }],
136
+ details: {},
137
+ isError: true,
138
+ });
139
+ });
140
+
141
+ // 处理 abort signal
142
+ if (signal) {
143
+ signal.addEventListener("abort", () => {
144
+ if (settled) return;
145
+ settled = true;
146
+ clearTimeout(timer);
147
+ try { child.kill(); } catch {}
148
+ resolve({
149
+ content: [{ type: "text", text: "Command cancelled." }],
150
+ details: {},
151
+ });
152
+ }, { once: true });
153
+ }
154
+ });
155
+ },
156
+ });
157
+
158
+ // bg_status 工具:查看/管理后台进程
159
+ pi.registerTool({
160
+ name: "bg_status",
161
+ label: "Background Process Status",
162
+ description: "Check status, view output, or stop background processes that were auto-backgrounded.",
163
+ parameters: Type.Object({
164
+ action: StringEnum(["list", "log", "stop"] as const, { description: "list=show all, log=view output, stop=kill process" }),
165
+ pid: Type.Optional(Type.Number({ description: "PID of the process (required for log/stop)" })),
166
+ }),
167
+ async execute(toolCallId, params) {
168
+ const { action, pid } = params;
169
+
170
+ if (action === "list") {
171
+ if (bgProcesses.size === 0) {
172
+ return { content: [{ type: "text", text: "No background processes." }], details: {} };
173
+ }
174
+ const lines = [...bgProcesses.values()].map((p) => {
175
+ const status = p.finished ? `⚪ stopped (exit ${p.exitCode ?? "?"})` : (isAlive(p.pid) ? "🟢 running" : "⚪ stopped");
176
+ return `PID: ${p.pid} | ${status} | Log: ${p.logFile}\n Cmd: ${p.command}`;
177
+ });
178
+ return { content: [{ type: "text", text: lines.join("\n\n") }], details: {} };
179
+ }
180
+
181
+ if (!pid) {
182
+ return { content: [{ type: "text", text: "Error: pid is required for log/stop" }], details: {}, isError: true };
183
+ }
184
+
185
+ const proc = bgProcesses.get(pid);
186
+
187
+ if (action === "log") {
188
+ const logFile = proc?.logFile;
189
+ if (logFile && existsSync(logFile)) {
190
+ try {
191
+ const content = readFileSync(logFile, "utf-8");
192
+ const tail = content.slice(-5000);
193
+ const truncated = content.length > 5000 ? `[...truncated, showing last 5000 chars]\n${tail}` : tail;
194
+ return { content: [{ type: "text", text: truncated || "(empty)" }], details: {} };
195
+ } catch (e: any) {
196
+ return { content: [{ type: "text", text: `Error reading log: ${e.message}` }], details: {}, isError: true };
197
+ }
198
+ }
199
+ return { content: [{ type: "text", text: "No log available for this PID." }], details: {} };
200
+ }
201
+
202
+ if (action === "stop") {
203
+ try {
204
+ process.kill(pid, "SIGTERM");
205
+ bgProcesses.delete(pid);
206
+ return { content: [{ type: "text", text: `Process ${pid} terminated.` }], details: {} };
207
+ } catch {
208
+ bgProcesses.delete(pid);
209
+ return { content: [{ type: "text", text: `Process ${pid} not found (already stopped?).` }], details: {} };
210
+ }
211
+ }
212
+
213
+ return { content: [{ type: "text", text: `Unknown action: ${action}` }], details: {}, isError: true };
214
+ },
215
+ });
216
+
217
+ // 清理:退出时杀掉所有后台进程
218
+ pi.on("session_shutdown", async () => {
219
+ for (const [pid, proc] of bgProcesses) {
220
+ if (!proc.finished) {
221
+ try { process.kill(pid, "SIGTERM"); } catch {}
222
+ }
223
+ }
224
+ bgProcesses.clear();
225
+ });
226
+ }
227
+
228
+ function isAlive(pid: number): boolean {
229
+ try { process.kill(pid, 0); return true; } catch { return false; }
230
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * oh-pi Compact Header — table-style startup info with dynamic column widths
3
+ */
4
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
5
+ import { VERSION } from "@mariozechner/pi-coding-agent";
6
+ import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
7
+
8
+ export default function (pi: ExtensionAPI) {
9
+ pi.on("session_start", async (_event, ctx) => {
10
+ if (!ctx.hasUI) return;
11
+
12
+ ctx.ui.setHeader((_tui, theme) => ({
13
+ render(width: number): string[] {
14
+ const d = (s: string) => theme.fg("dim", s);
15
+ const a = (s: string) => theme.fg("accent", s);
16
+
17
+ const cmds = pi.getCommands();
18
+ const prompts = cmds.filter(c => c.source === "prompt").map(c => `/${c.name}`).join(" ");
19
+ const skills = cmds.filter(c => c.source === "skill").map(c => c.name).join(" ");
20
+ const model = ctx.model ? `${ctx.model.id}` : "no model";
21
+ const thinking = pi.getThinkingLevel();
22
+ const provider = ctx.model?.provider ?? "";
23
+
24
+ const pad = (s: string, w: number) => s + " ".repeat(Math.max(0, w - visibleWidth(s)));
25
+ const t = (s: string) => truncateToWidth(s, width);
26
+ const sep = d(" │ ");
27
+
28
+ // Right two columns are fixed width
29
+ const rCol = [
30
+ [d("esc"), a("interrupt"), d("S-tab"), a("thinking")],
31
+ [d("^C"), a("clear/exit"), d("^O"), a("expand")],
32
+ [d("^P"), a("model"), d("^G"), a("editor")],
33
+ [d("/"), a("commands"), d("^V"), a("paste")],
34
+ [d("!"), a("bash"), d(""), a("")],
35
+ ];
36
+ const k1w = 6, v1w = 13, k2w = 6, v2w = 9;
37
+ const rightW = k1w + v1w + 3 + k2w + v2w + 3; // 3 for each sep
38
+
39
+ // Left column gets remaining space
40
+ const leftW = Math.max(20, width - rightW);
41
+ const lk = 9; // label width
42
+
43
+ const lCol = [
44
+ [d("version"), a(`v${VERSION} ${provider}`)],
45
+ [d("model"), a(model)],
46
+ [d("think"), a(thinking)],
47
+ [d(""), d("")],
48
+ [d(""), d("")],
49
+ ];
50
+
51
+ const lines: string[] = [""];
52
+ for (let i = 0; i < 5; i++) {
53
+ const [lk0, lv0] = lCol[i];
54
+ const [rk0, rv0, rk1, rv1] = rCol[i];
55
+ const left = truncateToWidth(pad(lk0, lk) + lv0, leftW);
56
+ const right = pad(rk0, k1w) + pad(rv0, v1w) + sep + pad(rk1, k2w) + rv1;
57
+ lines.push(t(pad(left, leftW) + sep + right));
58
+ }
59
+
60
+ if (prompts) lines.push(t(`${pad(d("prompts"), lk)}${a(prompts)}`));
61
+ if (skills) lines.push(t(`${pad(d("skills"), lk)}${a(skills)}`));
62
+ lines.push(d("─".repeat(width)));
63
+
64
+ return lines;
65
+ },
66
+ invalidate() {},
67
+ }));
68
+ });
69
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Custom Footer Extension — Enhanced status bar
3
+ *
4
+ * Displays: in/out/remaining tokens, cost, context%, elapsed, cwd, git branch, model
5
+ * Color-coded context usage: green <50%, yellow 50-75%, red >75%
6
+ */
7
+
8
+ import type { AssistantMessage } from "@mariozechner/pi-ai";
9
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
10
+ import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
11
+
12
+ export default function (pi: ExtensionAPI) {
13
+ let sessionStart = Date.now();
14
+
15
+ function formatElapsed(ms: number): string {
16
+ const s = Math.floor(ms / 1000);
17
+ if (s < 60) return `${s}s`;
18
+ const m = Math.floor(s / 60);
19
+ const rs = s % 60;
20
+ if (m < 60) return `${m}m${rs > 0 ? rs + "s" : ""}`;
21
+ const h = Math.floor(m / 60);
22
+ const rm = m % 60;
23
+ return `${h}h${rm > 0 ? rm + "m" : ""}`;
24
+ }
25
+
26
+ function fmt(n: number): string {
27
+ if (n < 1000) return `${n}`;
28
+ return `${(n / 1000).toFixed(1)}k`;
29
+ }
30
+
31
+ pi.on("session_start", async (_event, ctx) => {
32
+ sessionStart = Date.now();
33
+
34
+ ctx.ui.setFooter((tui, theme, footerData) => {
35
+ const unsub = footerData.onBranchChange(() => tui.requestRender());
36
+ const timer = setInterval(() => tui.requestRender(), 30000);
37
+
38
+ return {
39
+ dispose() { unsub(); clearInterval(timer); },
40
+ invalidate() {},
41
+ render(width: number): string[] {
42
+ let input = 0, output = 0, cost = 0;
43
+ for (const e of ctx.sessionManager.getBranch()) {
44
+ if (e.type === "message" && e.message.role === "assistant") {
45
+ const m = e.message as AssistantMessage;
46
+ input += m.usage.input;
47
+ output += m.usage.output;
48
+ cost += m.usage.cost.total;
49
+ }
50
+ }
51
+
52
+ const usage = ctx.getContextUsage();
53
+ const ctxWindow = usage?.contextWindow ?? 0;
54
+ const pct = usage?.percent ?? 0;
55
+ const remaining = Math.max(0, ctxWindow - (usage?.tokens ?? 0));
56
+
57
+ const pctColor = pct > 75 ? "error" : pct > 50 ? "warning" : "success";
58
+
59
+ const tokenStats = [
60
+ theme.fg("accent", `${fmt(input)}/${fmt(output)}`),
61
+ theme.fg("warning", `$${cost.toFixed(2)}`),
62
+ theme.fg(pctColor, `${pct.toFixed(0)}%`),
63
+ ].join(" ");
64
+
65
+ const elapsed = theme.fg("dim", `⏱${formatElapsed(Date.now() - sessionStart)}`);
66
+
67
+ const parts = process.cwd().split("/");
68
+ const short = parts.length > 2 ? parts.slice(-2).join("/") : process.cwd();
69
+ const cwdStr = theme.fg("muted", `⌂ ${short}`);
70
+
71
+ const branch = footerData.getGitBranch();
72
+ const branchStr = branch ? theme.fg("accent", `⎇ ${branch}`) : "";
73
+
74
+ const thinking = pi.getThinkingLevel();
75
+ const thinkColor = thinking === "high" ? "warning" : thinking === "medium" ? "accent" : thinking === "low" ? "dim" : "muted";
76
+ const modelId = ctx.model?.id || "no-model";
77
+ const modelStr = theme.fg(thinkColor, "◆") + " " + theme.fg("accent", modelId);
78
+
79
+ const sep = theme.fg("dim", " | ");
80
+ const leftParts = [modelStr, tokenStats, elapsed, cwdStr];
81
+ if (branchStr) leftParts.push(branchStr);
82
+ const left = leftParts.join(sep);
83
+
84
+ return [truncateToWidth(left, width)];
85
+ },
86
+ };
87
+ });
88
+ });
89
+
90
+ pi.on("session_switch", async (event, _ctx) => {
91
+ if (event.reason === "new") {
92
+ sessionStart = Date.now();
93
+ }
94
+ });
95
+ }