xtrm-tools 2.1.20 → 2.1.21
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/README.md +81 -709
- package/cli/package.json +1 -1
- package/config/pi/extensions/beads.ts +185 -0
- package/config/pi/extensions/core/adapter.ts +45 -0
- package/config/pi/extensions/core/index.ts +3 -0
- package/config/pi/extensions/core/logger.ts +45 -0
- package/config/pi/extensions/core/runner.ts +71 -0
- package/config/pi/extensions/custom-footer.ts +19 -53
- package/config/pi/extensions/main-guard-post-push.ts +44 -0
- package/config/pi/extensions/main-guard.ts +126 -0
- package/config/pi/extensions/quality-gates.ts +67 -0
- package/config/pi/extensions/service-skills.ts +88 -0
- package/config/pi/extensions/xtrm-loader.ts +89 -0
- package/hooks/README.md +35 -310
- package/package.json +1 -1
- package/config/pi/extensions/git-guard.ts +0 -45
- package/config/pi/extensions/safe-guard.ts +0 -46
package/cli/package.json
CHANGED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import type { ExtensionAPI, ToolCallEvent, ToolResultEvent } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { isToolCallEventType, isBashToolResult } from "@mariozechner/pi-coding-agent";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import * as fs from "node:fs";
|
|
5
|
+
import { SubprocessRunner, EventAdapter, Logger } from "./core";
|
|
6
|
+
|
|
7
|
+
const logger = new Logger({ namespace: "beads" });
|
|
8
|
+
|
|
9
|
+
export default function (pi: ExtensionAPI) {
|
|
10
|
+
const getCwd = (ctx: any) => ctx.cwd || process.cwd();
|
|
11
|
+
const isBeadsProject = (cwd: string) => fs.existsSync(path.join(cwd, ".beads"));
|
|
12
|
+
|
|
13
|
+
const getSessionClaim = async (sessionId: string, cwd: string): Promise<string | null> => {
|
|
14
|
+
const result = await SubprocessRunner.run("bd", ["kv", "get", `claimed:${sessionId}`], { cwd });
|
|
15
|
+
if (result.code === 0) return result.stdout.trim();
|
|
16
|
+
return null;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const setSessionClaim = async (sessionId: string, issueId: string, cwd: string): Promise<boolean> => {
|
|
20
|
+
const result = await SubprocessRunner.run("bd", ["kv", "set", `claimed:${sessionId}`, issueId], { cwd });
|
|
21
|
+
return result.code === 0;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const clearSessionClaim = async (sessionId: string, cwd: string): Promise<boolean> => {
|
|
25
|
+
const result = await SubprocessRunner.run("bd", ["kv", "clear", `claimed:${sessionId}`], { cwd });
|
|
26
|
+
return result.code === 0;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const getInProgressSummary = async (cwd: string): Promise<string | null> => {
|
|
30
|
+
const result = await SubprocessRunner.run("bd", ["list", "--status=in_progress"], { cwd });
|
|
31
|
+
if (result.code === 0 && result.stdout.includes("Total:")) {
|
|
32
|
+
return result.stdout.trim();
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const hasTrackableWork = async (cwd: string): Promise<boolean> => {
|
|
38
|
+
const result = await SubprocessRunner.run("bd", ["list"], { cwd });
|
|
39
|
+
if (result.code === 0 && result.stdout.includes("Total:")) {
|
|
40
|
+
const m = result.stdout.match(/Total:\s*\d+\s+issues?\s*\((\d+)\s+open,\s*(\d+)\s+in progress\)/);
|
|
41
|
+
if (m) {
|
|
42
|
+
const open = parseInt(m[1], 10);
|
|
43
|
+
const inProgress = parseInt(m[2], 10);
|
|
44
|
+
return (open + inProgress) > 0;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return false;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// 0. Register Custom Commands
|
|
51
|
+
pi.registerCommand({
|
|
52
|
+
name: "claim",
|
|
53
|
+
description: "Claim a beads issue for this session",
|
|
54
|
+
async execute(args, ctx) {
|
|
55
|
+
const cwd = getCwd(ctx);
|
|
56
|
+
if (!isBeadsProject(cwd)) {
|
|
57
|
+
ctx.ui.notify("Not a beads project.", "error");
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const issueId = args[0];
|
|
62
|
+
if (!issueId) {
|
|
63
|
+
ctx.ui.notify("Usage: /claim <issue-id>", "warning");
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Ensure issue is in_progress first
|
|
68
|
+
await SubprocessRunner.run("bd", ["update", issueId, "--status=in_progress"], { cwd });
|
|
69
|
+
|
|
70
|
+
const ok = await setSessionClaim(ctx.sessionManager.sessionId, issueId, cwd);
|
|
71
|
+
if (ok) {
|
|
72
|
+
ctx.ui.notify(`Claimed issue: ${issueId}`, "info");
|
|
73
|
+
} else {
|
|
74
|
+
ctx.ui.notify(`Failed to claim issue: ${issueId}`, "error");
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
pi.registerCommand({
|
|
80
|
+
name: "unclaim",
|
|
81
|
+
description: "Clear the beads issue claim for this session",
|
|
82
|
+
async execute(_args, ctx) {
|
|
83
|
+
const cwd = getCwd(ctx);
|
|
84
|
+
if (!isBeadsProject(cwd)) return;
|
|
85
|
+
|
|
86
|
+
const ok = await clearSessionClaim(ctx.sessionManager.sessionId, cwd);
|
|
87
|
+
if (ok) {
|
|
88
|
+
ctx.ui.notify("Claim cleared.", "info");
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// 1. Tool Call Interception (Edit Gate & Commit Gate)
|
|
94
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
95
|
+
const cwd = getCwd(ctx);
|
|
96
|
+
if (!isBeadsProject(cwd)) return undefined;
|
|
97
|
+
|
|
98
|
+
const sessionId = ctx.sessionManager.sessionId;
|
|
99
|
+
|
|
100
|
+
// A. Edit Gate
|
|
101
|
+
if (EventAdapter.isMutatingFileTool(event)) {
|
|
102
|
+
const claim = await getSessionClaim(sessionId, cwd);
|
|
103
|
+
if (claim) return undefined;
|
|
104
|
+
|
|
105
|
+
const hasWork = await hasTrackableWork(cwd);
|
|
106
|
+
if (!hasWork) return undefined;
|
|
107
|
+
|
|
108
|
+
const reason = "No active issue claim for this session. Use `/claim <id>` to track your work.";
|
|
109
|
+
if (ctx.hasUI) {
|
|
110
|
+
ctx.ui.notify("Beads: Edit blocked. Claim an issue first.", "warning");
|
|
111
|
+
}
|
|
112
|
+
return { block: true, reason };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// B. Commit Gate
|
|
116
|
+
if (isToolCallEventType("bash", event)) {
|
|
117
|
+
const command = event.input.command;
|
|
118
|
+
if (/\bgit\s+commit\b/.test(command)) {
|
|
119
|
+
const claim = await getSessionClaim(sessionId, cwd);
|
|
120
|
+
if (claim) {
|
|
121
|
+
const inProgress = await getInProgressSummary(cwd);
|
|
122
|
+
const reason = `Resolve open claim [${claim}] before committing. Use \`bd close ${claim}\` first.\n\n${inProgress || ""}`;
|
|
123
|
+
if (ctx.hasUI) {
|
|
124
|
+
ctx.ui.notify("Beads: Commit blocked. Close active claim first.", "warning");
|
|
125
|
+
}
|
|
126
|
+
return { block: true, reason };
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return undefined;
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// 2. Tool Result Interception (Memory Gate)
|
|
135
|
+
pi.on("tool_result", async (event, ctx) => {
|
|
136
|
+
const cwd = getCwd(ctx);
|
|
137
|
+
if (!isBeadsProject(cwd)) return undefined;
|
|
138
|
+
|
|
139
|
+
if (isBashToolResult(event)) {
|
|
140
|
+
const command = event.input.command;
|
|
141
|
+
// Also clear claim on bd close
|
|
142
|
+
if (/\bbd\s+close\b/.test(command) && !event.isError) {
|
|
143
|
+
await clearSessionClaim(ctx.sessionManager.sessionId, cwd);
|
|
144
|
+
|
|
145
|
+
const reminder = "\n\n**Beads Insight**: Work completed. Consider if this session produced insights worth persisting via `bd remember`.";
|
|
146
|
+
const newContent = [...event.content];
|
|
147
|
+
newContent.push({ type: "text", text: reminder });
|
|
148
|
+
return { content: newContent };
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return undefined;
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// 3. Compaction Support
|
|
155
|
+
pi.on("session_before_compact", async (event, ctx) => {
|
|
156
|
+
const cwd = getCwd(ctx);
|
|
157
|
+
if (!isBeadsProject(cwd)) return undefined;
|
|
158
|
+
|
|
159
|
+
const sessionId = ctx.sessionManager.sessionId;
|
|
160
|
+
const claim = await getSessionClaim(sessionId, cwd);
|
|
161
|
+
|
|
162
|
+
if (claim) {
|
|
163
|
+
return {
|
|
164
|
+
compaction: {
|
|
165
|
+
summary: (event.compaction?.summary || "") + `\n\nActive Beads Claim: ${claim}`,
|
|
166
|
+
firstKeptEntryId: event.preparation.firstKeptEntryId,
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
return undefined;
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// 4. Shutdown Warning
|
|
174
|
+
pi.on("session_shutdown", async (_event, ctx) => {
|
|
175
|
+
const cwd = getCwd(ctx);
|
|
176
|
+
if (!isBeadsProject(cwd)) return;
|
|
177
|
+
|
|
178
|
+
const sessionId = ctx.sessionManager.sessionId;
|
|
179
|
+
const claim = await getSessionClaim(sessionId, cwd);
|
|
180
|
+
|
|
181
|
+
if (claim && ctx.hasUI) {
|
|
182
|
+
ctx.ui.notify(`Warning: Exiting with active Beads claim [${claim}].`, "warning");
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { ExtensionAPI, ToolCallEvent } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
export class EventAdapter {
|
|
4
|
+
/**
|
|
5
|
+
* Checks if the tool event is a mutating file operation (write, edit, etc).
|
|
6
|
+
*/
|
|
7
|
+
static isMutatingFileTool(event: ToolCallEvent<any, any>): boolean {
|
|
8
|
+
const tools = [
|
|
9
|
+
"write",
|
|
10
|
+
"edit",
|
|
11
|
+
"replace_content",
|
|
12
|
+
"replace_lines",
|
|
13
|
+
"delete_lines",
|
|
14
|
+
"insert_at_line",
|
|
15
|
+
"create_text_file",
|
|
16
|
+
"rename_symbol",
|
|
17
|
+
"replace_symbol_body",
|
|
18
|
+
"insert_after_symbol",
|
|
19
|
+
"insert_before_symbol",
|
|
20
|
+
];
|
|
21
|
+
return tools.includes(event.toolName);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Extracts the target path from a tool input, resolving against the current working directory.
|
|
26
|
+
*/
|
|
27
|
+
static extractPathFromToolInput(event: ToolCallEvent<any, any>, cwd: string): string | null {
|
|
28
|
+
const input = event.input;
|
|
29
|
+
if (!input) return null;
|
|
30
|
+
|
|
31
|
+
const pathRaw = input.path || input.file || input.filePath;
|
|
32
|
+
if (typeof pathRaw === "string") {
|
|
33
|
+
return pathRaw; // Usually Pi passes absolute paths anyway or paths relative to root
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Safely formats a block reason string to ensure UI readiness.
|
|
41
|
+
*/
|
|
42
|
+
static formatBlockReason(prefix: string, details: string): string {
|
|
43
|
+
return `${prefix}: ${details}`;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export type LogLevel = "debug" | "info" | "warn" | "error";
|
|
2
|
+
|
|
3
|
+
export interface LoggerOptions {
|
|
4
|
+
namespace: string;
|
|
5
|
+
level?: LogLevel;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export class Logger {
|
|
9
|
+
private namespace: string;
|
|
10
|
+
private level: LogLevel;
|
|
11
|
+
|
|
12
|
+
constructor(options: LoggerOptions) {
|
|
13
|
+
this.namespace = options.namespace;
|
|
14
|
+
this.level = options.level || "info";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
private shouldLog(level: LogLevel): boolean {
|
|
18
|
+
const levels: LogLevel[] = ["debug", "info", "warn", "error"];
|
|
19
|
+
return levels.indexOf(level) >= levels.indexOf(this.level);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
debug(message: string, ...args: any[]) {
|
|
23
|
+
if (this.shouldLog("debug")) {
|
|
24
|
+
console.debug(`[${this.namespace}] DEBUG: ${message}`, ...args);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
info(message: string, ...args: any[]) {
|
|
29
|
+
if (this.shouldLog("info")) {
|
|
30
|
+
console.info(`[${this.namespace}] INFO: ${message}`, ...args);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
warn(message: string, ...args: any[]) {
|
|
35
|
+
if (this.shouldLog("warn")) {
|
|
36
|
+
console.warn(`[${this.namespace}] WARN: ${message}`, ...args);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
error(message: string, ...args: any[]) {
|
|
41
|
+
if (this.shouldLog("error")) {
|
|
42
|
+
console.error(`[${this.namespace}] ERROR: ${message}`, ...args);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { execFile, spawnSync } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
|
|
4
|
+
const execFileAsync = promisify(execFile);
|
|
5
|
+
|
|
6
|
+
export interface RunOptions {
|
|
7
|
+
timeoutMs?: number;
|
|
8
|
+
cwd?: string;
|
|
9
|
+
env?: Record<string, string>;
|
|
10
|
+
input?: string; // Standard input
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface RunResult {
|
|
14
|
+
code: number;
|
|
15
|
+
stdout: string;
|
|
16
|
+
stderr: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class SubprocessRunner {
|
|
20
|
+
/**
|
|
21
|
+
* Run a command deterministically with a timeout and optional stdin.
|
|
22
|
+
*/
|
|
23
|
+
static async run(
|
|
24
|
+
command: string,
|
|
25
|
+
args: string[],
|
|
26
|
+
options: RunOptions = {}
|
|
27
|
+
): Promise<RunResult> {
|
|
28
|
+
const timeout = options.timeoutMs ?? 10000;
|
|
29
|
+
const cwd = options.cwd ?? process.cwd();
|
|
30
|
+
const env = { ...process.env, ...options.env };
|
|
31
|
+
|
|
32
|
+
if (options.input !== undefined) {
|
|
33
|
+
// Use spawnSync for stdin support if input is provided
|
|
34
|
+
const result = spawnSync(command, args, {
|
|
35
|
+
cwd,
|
|
36
|
+
env,
|
|
37
|
+
input: options.input,
|
|
38
|
+
encoding: "utf8",
|
|
39
|
+
timeout,
|
|
40
|
+
maxBuffer: 1024 * 1024 * 10,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
code: result.status ?? 1,
|
|
45
|
+
stdout: (result.stdout ?? "").trim(),
|
|
46
|
+
stderr: (result.stderr ?? "").trim(),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
const result = await execFileAsync(command, args, {
|
|
52
|
+
timeout,
|
|
53
|
+
cwd,
|
|
54
|
+
env,
|
|
55
|
+
maxBuffer: 1024 * 1024 * 10,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
code: 0,
|
|
60
|
+
stdout: result.stdout.trim(),
|
|
61
|
+
stderr: result.stderr.trim(),
|
|
62
|
+
};
|
|
63
|
+
} catch (error: any) {
|
|
64
|
+
return {
|
|
65
|
+
code: error.code ?? 1,
|
|
66
|
+
stdout: (error.stdout ?? "").trim(),
|
|
67
|
+
stderr: (error.stderr ?? error.message ?? "").trim(),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -1,68 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Custom Footer Extension
|
|
2
|
+
* XTRM Custom Footer Extension
|
|
3
3
|
*
|
|
4
|
-
* Displays:
|
|
5
|
-
* Color-coded context usage: green <50%, yellow 50-75%, red >75%
|
|
4
|
+
* Displays: XTRM brand, Turn count, Model, Context%, CWD, Git branch
|
|
6
5
|
*/
|
|
7
6
|
|
|
8
|
-
import type { AssistantMessage } from "@mariozechner/pi-ai";
|
|
9
7
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
10
|
-
import { truncateToWidth
|
|
8
|
+
import { truncateToWidth } from "@mariozechner/pi-tui";
|
|
11
9
|
|
|
12
10
|
export default function (pi: ExtensionAPI) {
|
|
13
|
-
let
|
|
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
|
-
}
|
|
11
|
+
let turnCount = 0;
|
|
30
12
|
|
|
31
13
|
pi.on("session_start", async (_event, ctx) => {
|
|
32
|
-
sessionStart = Date.now();
|
|
33
|
-
|
|
34
14
|
ctx.ui.setFooter((tui, theme, footerData) => {
|
|
35
15
|
const unsub = footerData.onBranchChange(() => tui.requestRender());
|
|
36
|
-
|
|
37
|
-
|
|
16
|
+
|
|
38
17
|
return {
|
|
39
|
-
dispose() { unsub();
|
|
18
|
+
dispose() { unsub(); },
|
|
40
19
|
invalidate() {},
|
|
41
20
|
render(width: number): string[] {
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
}
|
|
21
|
+
const brand = theme.fg("accent", "XTRM");
|
|
22
|
+
const turns = theme.fg("dim", `[Turn ${turnCount}]`);
|
|
51
23
|
|
|
52
24
|
const usage = ctx.getContextUsage();
|
|
53
|
-
const ctxWindow = usage?.contextWindow ?? 0;
|
|
54
25
|
const pct = usage?.percent ?? 0;
|
|
55
|
-
const remaining = Math.max(0, ctxWindow - (usage?.tokens ?? 0));
|
|
56
|
-
|
|
57
26
|
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)}`);
|
|
27
|
+
const usageStr = theme.fg(pctColor, `${pct.toFixed(0)}%`);
|
|
66
28
|
|
|
67
29
|
const parts = process.cwd().split("/");
|
|
68
30
|
const short = parts.length > 2 ? parts.slice(-2).join("/") : process.cwd();
|
|
@@ -71,25 +33,29 @@ export default function (pi: ExtensionAPI) {
|
|
|
71
33
|
const branch = footerData.getGitBranch();
|
|
72
34
|
const branchStr = branch ? theme.fg("accent", `⎇ ${branch}`) : "";
|
|
73
35
|
|
|
74
|
-
const thinking = pi.getThinkingLevel();
|
|
75
|
-
const thinkColor = thinking === "high" ? "warning" : thinking === "medium" ? "accent" : thinking === "low" ? "dim" : "muted";
|
|
76
36
|
const modelId = ctx.model?.id || "no-model";
|
|
77
|
-
const modelStr = theme.fg(
|
|
37
|
+
const modelStr = theme.fg("accent", modelId);
|
|
78
38
|
|
|
79
39
|
const sep = theme.fg("dim", " | ");
|
|
80
|
-
|
|
40
|
+
|
|
41
|
+
// Layout: XTRM [Turn 1] | model | 10% | ⌂ dir | ⎇ branch
|
|
42
|
+
const leftParts = [`${brand} ${turns}`, modelStr, usageStr, cwdStr];
|
|
81
43
|
if (branchStr) leftParts.push(branchStr);
|
|
44
|
+
|
|
82
45
|
const left = leftParts.join(sep);
|
|
83
|
-
|
|
84
46
|
return [truncateToWidth(left, width)];
|
|
85
47
|
},
|
|
86
48
|
};
|
|
87
49
|
});
|
|
88
50
|
});
|
|
89
51
|
|
|
52
|
+
pi.on("turn_start", async () => {
|
|
53
|
+
turnCount++;
|
|
54
|
+
});
|
|
55
|
+
|
|
90
56
|
pi.on("session_switch", async (event, _ctx) => {
|
|
91
57
|
if (event.reason === "new") {
|
|
92
|
-
|
|
58
|
+
turnCount = 0;
|
|
93
59
|
}
|
|
94
60
|
});
|
|
95
61
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { ExtensionAPI, ToolResultEvent } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { isBashToolResult } from "@mariozechner/pi-coding-agent";
|
|
3
|
+
import { SubprocessRunner, Logger } from "./core";
|
|
4
|
+
|
|
5
|
+
const logger = new Logger({ namespace: "main-guard-post-push" });
|
|
6
|
+
|
|
7
|
+
export default function (pi: ExtensionAPI) {
|
|
8
|
+
const getProtectedBranches = (): string[] => {
|
|
9
|
+
const env = process.env.MAIN_GUARD_PROTECTED_BRANCHES;
|
|
10
|
+
if (env) return env.split(",").map(b => b.trim()).filter(Boolean);
|
|
11
|
+
return ["main", "master"];
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
pi.on("tool_result", async (event, ctx) => {
|
|
15
|
+
const cwd = ctx.cwd || process.cwd();
|
|
16
|
+
if (!isBashToolResult(event) || event.isError) return undefined;
|
|
17
|
+
|
|
18
|
+
const cmd = event.input.command.trim();
|
|
19
|
+
if (!/\bgit\s+push\b/.test(cmd)) return undefined;
|
|
20
|
+
|
|
21
|
+
// Check if we pushed to a protected branch
|
|
22
|
+
const protectedBranches = getProtectedBranches();
|
|
23
|
+
const tokens = cmd.split(/\s+/);
|
|
24
|
+
const lastToken = tokens[tokens.length - 1];
|
|
25
|
+
if (protectedBranches.some(b => lastToken === b || lastToken.endsWith(`:${b}`))) {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Success! Suggest PR workflow
|
|
30
|
+
const reminder = "\n\n**Main-Guard**: Push successful. Next steps:\n" +
|
|
31
|
+
" 1. `gh pr create --fill` (if not already open)\n" +
|
|
32
|
+
" 2. `gh pr merge --squash` (once approved)\n" +
|
|
33
|
+
" 3. `git checkout main && git reset --hard origin/main` (sync local)";
|
|
34
|
+
|
|
35
|
+
const newContent = [...event.content];
|
|
36
|
+
newContent.push({ type: "text", text: reminder });
|
|
37
|
+
|
|
38
|
+
if (ctx.hasUI) {
|
|
39
|
+
ctx.ui.notify("Main-Guard: Suggesting PR workflow", "info");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return { content: newContent };
|
|
43
|
+
});
|
|
44
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import type { ExtensionAPI, ToolCallEvent } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { isToolCallEventType } from "@mariozechner/pi-coding-agent";
|
|
3
|
+
import { SubprocessRunner, EventAdapter, Logger } from "./core";
|
|
4
|
+
|
|
5
|
+
const logger = new Logger({ namespace: "main-guard" });
|
|
6
|
+
|
|
7
|
+
export default function (pi: ExtensionAPI) {
|
|
8
|
+
const getProtectedBranches = (): string[] => {
|
|
9
|
+
const env = process.env.MAIN_GUARD_PROTECTED_BRANCHES;
|
|
10
|
+
if (env) return env.split(",").map(b => b.trim()).filter(Boolean);
|
|
11
|
+
return ["main", "master"];
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const getCurrentBranch = async (cwd: string): Promise<string | null> => {
|
|
15
|
+
const result = await SubprocessRunner.run("git", ["branch", "--show-current"], { cwd });
|
|
16
|
+
if (result.code === 0) return result.stdout;
|
|
17
|
+
return null;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const protectedPaths = [".env", ".git/", "node_modules/"];
|
|
21
|
+
|
|
22
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
23
|
+
const cwd = ctx.cwd || process.cwd();
|
|
24
|
+
|
|
25
|
+
// 1. Safety Check: Protected Paths (Global)
|
|
26
|
+
if (EventAdapter.isMutatingFileTool(event)) {
|
|
27
|
+
const path = EventAdapter.extractPathFromToolInput(event, cwd);
|
|
28
|
+
if (path && protectedPaths.some((p) => path.includes(p))) {
|
|
29
|
+
const reason = `Path "${path}" is protected. Edits to sensitive system files are restricted.`;
|
|
30
|
+
if (ctx.hasUI) {
|
|
31
|
+
ctx.ui.notify(`Safety: Blocked edit to protected path`, "error");
|
|
32
|
+
}
|
|
33
|
+
return { block: true, reason };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// 2. Safety Check: Dangerous Commands (Global)
|
|
38
|
+
if (isToolCallEventType("bash", event)) {
|
|
39
|
+
const cmd = event.input.command.trim();
|
|
40
|
+
if (cmd.includes("rm -rf") && !cmd.includes("--help")) {
|
|
41
|
+
if (ctx.hasUI) {
|
|
42
|
+
const ok = await ctx.ui.confirm("Dangerous Command", `Allow execution of: ${cmd}?`);
|
|
43
|
+
if (!ok) return { block: true, reason: "Blocked by user confirmation" };
|
|
44
|
+
} else {
|
|
45
|
+
return { block: true, reason: "Dangerous command 'rm -rf' blocked in non-interactive mode" };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// 3. Main-Guard: Branch Protection
|
|
51
|
+
const protectedBranches = getProtectedBranches();
|
|
52
|
+
const branch = await getCurrentBranch(cwd);
|
|
53
|
+
|
|
54
|
+
if (branch && protectedBranches.includes(branch)) {
|
|
55
|
+
// A. Mutating File Tools on Main
|
|
56
|
+
if (EventAdapter.isMutatingFileTool(event)) {
|
|
57
|
+
const reason = `On protected branch '${branch}'. Checkout a feature branch first: \`git checkout -b feature/<name>\``;
|
|
58
|
+
if (ctx.hasUI) {
|
|
59
|
+
ctx.ui.notify(`Main-Guard: Blocked edit on ${branch}`, "error");
|
|
60
|
+
}
|
|
61
|
+
return { block: true, reason };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// B. Bash Commands on Main
|
|
65
|
+
if (isToolCallEventType("bash", event)) {
|
|
66
|
+
const cmd = event.input.command.trim();
|
|
67
|
+
|
|
68
|
+
// Emergency override
|
|
69
|
+
if (process.env.MAIN_GUARD_ALLOW_BASH === "1") return undefined;
|
|
70
|
+
|
|
71
|
+
// Enforce squash-only PR merges
|
|
72
|
+
if (/^gh\s+pr\s+merge\b/.test(cmd)) {
|
|
73
|
+
if (!/--squash\b/.test(cmd)) {
|
|
74
|
+
const reason = "Squash only: use `gh pr merge --squash` (or MAIN_GUARD_ALLOW_BASH=1)";
|
|
75
|
+
return { block: true, reason };
|
|
76
|
+
}
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Safe allowlist
|
|
81
|
+
const SAFE_BASH_PATTERNS = [
|
|
82
|
+
/^git\s+(status|log|diff|branch|show|describe|fetch|remote|config)\b/,
|
|
83
|
+
/^git\s+pull\b/,
|
|
84
|
+
/^git\s+stash\b/,
|
|
85
|
+
/^git\s+worktree\b/,
|
|
86
|
+
/^git\s+checkout\s+-b\s+\S+/,
|
|
87
|
+
/^git\s+switch\s+-c\s+\S+/,
|
|
88
|
+
...protectedBranches.map(b => new RegExp(`^git\\s+reset\\s+--hard\\s+origin/${b}\\b`)),
|
|
89
|
+
/^gh\s+/,
|
|
90
|
+
/^bd\s+/,
|
|
91
|
+
/^touch\s+\.beads\//,
|
|
92
|
+
];
|
|
93
|
+
|
|
94
|
+
if (SAFE_BASH_PATTERNS.some(p => p.test(cmd))) {
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Specific blocks
|
|
99
|
+
if (/\bgit\s+commit\b/.test(cmd)) {
|
|
100
|
+
return { block: true, reason: `No commits on '${branch}' — use a feature branch.` };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (/\bgit\s+push\b/.test(cmd)) {
|
|
104
|
+
const tokens = cmd.split(/\s+/);
|
|
105
|
+
const lastToken = tokens[tokens.length - 1];
|
|
106
|
+
const explicitProtected = protectedBranches.some(b => lastToken === b || lastToken.endsWith(`:${b}`));
|
|
107
|
+
const impliedProtected = tokens.length <= 3 && protectedBranches.includes(branch);
|
|
108
|
+
|
|
109
|
+
if (explicitProtected || impliedProtected) {
|
|
110
|
+
return { block: true, reason: `No direct push to '${branch}' — push a feature branch and open a PR.` };
|
|
111
|
+
}
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Default deny
|
|
116
|
+
const reason = `Bash restricted on '${branch}'. Allowed: git status/log/diff/pull/stash, gh, bd.\n Exit: git checkout -b feature/<name>`;
|
|
117
|
+
if (ctx.hasUI) {
|
|
118
|
+
ctx.ui.notify("Main-Guard: Command blocked", "error");
|
|
119
|
+
}
|
|
120
|
+
return { block: true, reason };
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return undefined;
|
|
125
|
+
});
|
|
126
|
+
}
|