@gonrocca/zero-pi 0.1.12 → 0.1.14
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/LICENSE +21 -21
- package/README.md +296 -258
- package/extensions/autotune-extension.ts +250 -250
- package/extensions/autotune.ts +558 -520
- package/extensions/conversation-resume.ts +400 -399
- package/extensions/provider-guard-extension.ts +195 -195
- package/extensions/provider-guard.ts +183 -183
- package/extensions/spec-merge-extension.ts +286 -286
- package/extensions/spec-merge.ts +373 -373
- package/extensions/startup-banner.ts +237 -237
- package/extensions/win-tree-kill.ts +114 -0
- package/extensions/working-phrases.ts +295 -295
- package/extensions/zero-models.ts +463 -333
- package/package.json +75 -73
- package/prompts/forge.md +34 -34
- package/prompts/orchestrator.md +292 -246
- package/prompts/phases/build.md +20 -20
- package/prompts/phases/explore.md +22 -22
- package/prompts/phases/plan.md +82 -82
- package/prompts/phases/veredicto.md +30 -30
- package/skills/{sdd-routing.md → sdd-routing/SKILL.md} +51 -51
- package/skills/{skill-loop.md → skill-loop/SKILL.md} +29 -29
- package/themes/zero-sdd.json +76 -76
|
@@ -1,237 +1,237 @@
|
|
|
1
|
-
// zero-pi - animated startup banner.
|
|
2
|
-
//
|
|
3
|
-
// A pi extension that renders the ZERO wordmark as ASCII-safe Tetris cells.
|
|
4
|
-
// The animated mode progressively assembles the wordmark from the bottom up,
|
|
5
|
-
// then settles into the complete banner. The extension stays dependency-free
|
|
6
|
-
// and keeps the historical ZERO_BANNER controls.
|
|
7
|
-
|
|
8
|
-
type RGB = [number, number, number];
|
|
9
|
-
|
|
10
|
-
const COLORS: Record<string, RGB> = {
|
|
11
|
-
cyan: [80, 210, 255],
|
|
12
|
-
blue: [116, 151, 255],
|
|
13
|
-
mint: [79, 221, 171],
|
|
14
|
-
amber: [238, 190, 92],
|
|
15
|
-
rose: [255, 106, 122],
|
|
16
|
-
violet: [175, 138, 255],
|
|
17
|
-
peak: [255, 245, 218],
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
/** Number of rows in every glyph. */
|
|
21
|
-
const ROWS = 6;
|
|
22
|
-
|
|
23
|
-
// Five-cell-wide glyphs. Non-space characters become visible Tetris cells.
|
|
24
|
-
const FONT: Record<string, string[]> = {
|
|
25
|
-
Z: ["ZZZZZ", " ZZ", " ZZ ", " ZZ ", "ZZ ", "ZZZZZ"],
|
|
26
|
-
E: ["EEEEE", "EE ", "EEEE ", "EE ", "EE ", "EEEEE"],
|
|
27
|
-
R: ["RRRR ", "RR RR", "RRRR ", "RR RR", "RR R", "RR R"],
|
|
28
|
-
O: [" OOO ", "OO OO", "OO OO", "OO OO", "OO OO", " OOO "],
|
|
29
|
-
P: ["PPPP ", "PP PP", "PP PP", "PPPP ", "PP ", "PP "],
|
|
30
|
-
I: ["IIIII", " II ", " II ", " II ", " II ", "IIIII"],
|
|
31
|
-
"-": [" ", " ", "-----", " ", " ", " "],
|
|
32
|
-
" ": [" ", " ", " ", " ", " ", " "],
|
|
33
|
-
};
|
|
34
|
-
|
|
35
|
-
const PIECE_COLORS: Record<string, RGB> = {
|
|
36
|
-
Z: COLORS.cyan,
|
|
37
|
-
E: COLORS.amber,
|
|
38
|
-
R: COLORS.mint,
|
|
39
|
-
O: COLORS.rose,
|
|
40
|
-
P: COLORS.violet,
|
|
41
|
-
I: COLORS.blue,
|
|
42
|
-
"-": COLORS.amber,
|
|
43
|
-
};
|
|
44
|
-
|
|
45
|
-
interface Matrix {
|
|
46
|
-
rows: string[];
|
|
47
|
-
width: number;
|
|
48
|
-
totalCells: number;
|
|
49
|
-
ranks: Map<string, number>;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function glyphFor(ch: string): string[] {
|
|
53
|
-
return FONT[ch.toUpperCase()] ?? FONT[" "];
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function matrixForText(text: string): Matrix {
|
|
57
|
-
const rows = Array.from({ length: ROWS }, () => "");
|
|
58
|
-
for (const [index, raw] of Array.from(text).entries()) {
|
|
59
|
-
const glyph = glyphFor(raw);
|
|
60
|
-
for (let r = 0; r < ROWS; r++) {
|
|
61
|
-
rows[r] += (index === 0 ? "" : " ") + glyph[r];
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
const cells: Array<{ row: number; col: number }> = [];
|
|
66
|
-
for (let row = 0; row < ROWS; row++) {
|
|
67
|
-
for (let col = 0; col < rows[row].length; col++) {
|
|
68
|
-
if (rows[row][col] !== " ") cells.push({ row, col });
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// Tetris reads as a pile building upward: bottom rows settle first.
|
|
73
|
-
cells.sort((a, b) => b.row - a.row || a.col - b.col);
|
|
74
|
-
|
|
75
|
-
const ranks = new Map<string, number>();
|
|
76
|
-
for (const [rank, cell] of cells.entries()) {
|
|
77
|
-
ranks.set(`${cell.row}:${cell.col}`, rank);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
return {
|
|
81
|
-
rows,
|
|
82
|
-
width: rows[0]?.length ?? 0,
|
|
83
|
-
totalCells: cells.length,
|
|
84
|
-
ranks,
|
|
85
|
-
};
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/** Lay a word out as six rows of ASCII-safe Tetris cells. */
|
|
89
|
-
export function bannerLines(text = "ZERO"): string[] {
|
|
90
|
-
return matrixForText(text).rows.map((row) =>
|
|
91
|
-
Array.from(row)
|
|
92
|
-
.map((cell) => (cell === " " ? " " : "[]"))
|
|
93
|
-
.join(""),
|
|
94
|
-
);
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
function lerp(a: number, b: number, t: number): number {
|
|
98
|
-
return a + (b - a) * t;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
function lerpColor(c1: RGB, c2: RGB, t: number): RGB {
|
|
102
|
-
const k = Math.max(0, Math.min(1, t));
|
|
103
|
-
return [
|
|
104
|
-
Math.round(lerp(c1[0], c2[0], k)),
|
|
105
|
-
Math.round(lerp(c1[1], c2[1], k)),
|
|
106
|
-
Math.round(lerp(c1[2], c2[2], k)),
|
|
107
|
-
];
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
function ansiFg([r, g, b]: RGB, text: string): string {
|
|
111
|
-
return `\x1b[38;2;${r};${g};${b}m${text}\x1b[0m`;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
function colorForPiece(piece: string, row: number, rank: number, active: boolean): RGB {
|
|
115
|
-
if (active) return COLORS.peak;
|
|
116
|
-
const base = PIECE_COLORS[piece] ?? COLORS.blue;
|
|
117
|
-
const heightWarmth = (ROWS - 1 - row) / Math.max(1, ROWS - 1);
|
|
118
|
-
const rankPulse = (rank % 7) / 18;
|
|
119
|
-
return lerpColor(base, COLORS.peak, heightWarmth * 0.18 + rankPulse);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
function paintMatrixLine(matrix: Matrix, row: number, visibleCells: number): string {
|
|
123
|
-
let out = "";
|
|
124
|
-
const line = matrix.rows[row] ?? "";
|
|
125
|
-
for (let col = 0; col < matrix.width; col++) {
|
|
126
|
-
const piece = line[col] ?? " ";
|
|
127
|
-
if (piece === " ") {
|
|
128
|
-
out += " ";
|
|
129
|
-
continue;
|
|
130
|
-
}
|
|
131
|
-
const rank = matrix.ranks.get(`${row}:${col}`) ?? Number.POSITIVE_INFINITY;
|
|
132
|
-
if (rank >= visibleCells) {
|
|
133
|
-
out += " ";
|
|
134
|
-
continue;
|
|
135
|
-
}
|
|
136
|
-
out += ansiFg(colorForPiece(piece, row, rank, rank === visibleCells - 1), "[]");
|
|
137
|
-
}
|
|
138
|
-
return out;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
interface OutStream {
|
|
142
|
-
write(chunk: string): unknown;
|
|
143
|
-
isTTY?: boolean;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/** Whether colour output is appropriate for the given stream. */
|
|
147
|
-
function shouldColor(stream: OutStream): boolean {
|
|
148
|
-
if (process.env.NO_COLOR) return false;
|
|
149
|
-
if (process.env.FORCE_COLOR) return true;
|
|
150
|
-
return Boolean(stream && stream.isTTY);
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
/** The three banner modes, resolved from the ZERO_BANNER environment variable. */
|
|
154
|
-
export type BannerMode = "off" | "static" | "shimmer";
|
|
155
|
-
|
|
156
|
-
/** Resolve the banner mode from an environment-variable value. */
|
|
157
|
-
export function resolveBannerMode(raw: string | undefined): BannerMode {
|
|
158
|
-
const value = (raw ?? "").trim().toLowerCase();
|
|
159
|
-
if (value === "off" || value === "0" || value === "false" || value === "none") return "off";
|
|
160
|
-
if (value === "static" || value === "plain" || value === "no-animate") return "static";
|
|
161
|
-
return "shimmer";
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
// Atomics.wait gives us a synchronous sleep without adding a dependency. The
|
|
165
|
-
// banner renders before Pi's TUI takes over, so blocking briefly is intentional.
|
|
166
|
-
const SLEEP_SLOT = new Int32Array(new SharedArrayBuffer(4));
|
|
167
|
-
function sleepSync(ms: number): void {
|
|
168
|
-
if (ms > 0) Atomics.wait(SLEEP_SLOT, 0, 0, ms);
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
function easeOutCubic(t: number): number {
|
|
172
|
-
return 1 - Math.pow(1 - t, 3);
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
/** Options for {@link renderBanner}. */
|
|
176
|
-
export interface RenderOptions {
|
|
177
|
-
mode?: BannerMode;
|
|
178
|
-
text?: string;
|
|
179
|
-
frames?: number;
|
|
180
|
-
frameMs?: number;
|
|
181
|
-
stream?: OutStream;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
/**
|
|
185
|
-
* Render the ZERO banner synchronously.
|
|
186
|
-
*
|
|
187
|
-
* On a non-colour stream the banner is printed plain. Otherwise the animated
|
|
188
|
-
* mode assembles cells frame by frame, then settles on the completed wordmark.
|
|
189
|
-
*/
|
|
190
|
-
export function renderBanner(options: RenderOptions = {}): void {
|
|
191
|
-
const mode = options.mode ?? resolveBannerMode(process.env.ZERO_BANNER);
|
|
192
|
-
if (mode === "off") return;
|
|
193
|
-
|
|
194
|
-
const stream: OutStream = options.stream ?? process.stdout;
|
|
195
|
-
const matrix = matrixForText(options.text ?? "ZERO");
|
|
196
|
-
|
|
197
|
-
if (!shouldColor(stream)) {
|
|
198
|
-
stream.write("\n" + bannerLines(options.text ?? "ZERO").join("\n") + "\n\n");
|
|
199
|
-
return;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
stream.write("\n" + Array.from({ length: ROWS }, () => "").join("\n") + "\n");
|
|
203
|
-
|
|
204
|
-
if (mode === "shimmer") {
|
|
205
|
-
const frames = Math.max(2, options.frames ?? 42);
|
|
206
|
-
const frameMs = Math.max(1, options.frameMs ?? 18);
|
|
207
|
-
for (let f = 0; f < frames; f++) {
|
|
208
|
-
const t = easeOutCubic(f / (frames - 1));
|
|
209
|
-
const visibleCells = Math.max(1, Math.ceil(matrix.totalCells * t));
|
|
210
|
-
stream.write(`\x1b[${ROWS}A\r`);
|
|
211
|
-
for (let r = 0; r < ROWS; r++) {
|
|
212
|
-
stream.write("\x1b[2K" + paintMatrixLine(matrix, r, visibleCells) + "\n");
|
|
213
|
-
}
|
|
214
|
-
sleepSync(frameMs);
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
stream.write(`\x1b[${ROWS}A\r`);
|
|
219
|
-
for (let r = 0; r < ROWS; r++) {
|
|
220
|
-
stream.write("\x1b[2K" + paintMatrixLine(matrix, r, matrix.totalCells) + "\n");
|
|
221
|
-
}
|
|
222
|
-
stream.write("\n");
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
/**
|
|
226
|
-
* The pi extension entry point.
|
|
227
|
-
*
|
|
228
|
-
* The pi argument is accepted for API compatibility. The banner is rendered
|
|
229
|
-
* defensively: visual sugar should never break an interactive session.
|
|
230
|
-
*/
|
|
231
|
-
export default function register(_pi?: unknown): void {
|
|
232
|
-
try {
|
|
233
|
-
renderBanner();
|
|
234
|
-
} catch {
|
|
235
|
-
// A banner failure must never break a pi session.
|
|
236
|
-
}
|
|
237
|
-
}
|
|
1
|
+
// zero-pi - animated startup banner.
|
|
2
|
+
//
|
|
3
|
+
// A pi extension that renders the ZERO wordmark as ASCII-safe Tetris cells.
|
|
4
|
+
// The animated mode progressively assembles the wordmark from the bottom up,
|
|
5
|
+
// then settles into the complete banner. The extension stays dependency-free
|
|
6
|
+
// and keeps the historical ZERO_BANNER controls.
|
|
7
|
+
|
|
8
|
+
type RGB = [number, number, number];
|
|
9
|
+
|
|
10
|
+
const COLORS: Record<string, RGB> = {
|
|
11
|
+
cyan: [80, 210, 255],
|
|
12
|
+
blue: [116, 151, 255],
|
|
13
|
+
mint: [79, 221, 171],
|
|
14
|
+
amber: [238, 190, 92],
|
|
15
|
+
rose: [255, 106, 122],
|
|
16
|
+
violet: [175, 138, 255],
|
|
17
|
+
peak: [255, 245, 218],
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** Number of rows in every glyph. */
|
|
21
|
+
const ROWS = 6;
|
|
22
|
+
|
|
23
|
+
// Five-cell-wide glyphs. Non-space characters become visible Tetris cells.
|
|
24
|
+
const FONT: Record<string, string[]> = {
|
|
25
|
+
Z: ["ZZZZZ", " ZZ", " ZZ ", " ZZ ", "ZZ ", "ZZZZZ"],
|
|
26
|
+
E: ["EEEEE", "EE ", "EEEE ", "EE ", "EE ", "EEEEE"],
|
|
27
|
+
R: ["RRRR ", "RR RR", "RRRR ", "RR RR", "RR R", "RR R"],
|
|
28
|
+
O: [" OOO ", "OO OO", "OO OO", "OO OO", "OO OO", " OOO "],
|
|
29
|
+
P: ["PPPP ", "PP PP", "PP PP", "PPPP ", "PP ", "PP "],
|
|
30
|
+
I: ["IIIII", " II ", " II ", " II ", " II ", "IIIII"],
|
|
31
|
+
"-": [" ", " ", "-----", " ", " ", " "],
|
|
32
|
+
" ": [" ", " ", " ", " ", " ", " "],
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const PIECE_COLORS: Record<string, RGB> = {
|
|
36
|
+
Z: COLORS.cyan,
|
|
37
|
+
E: COLORS.amber,
|
|
38
|
+
R: COLORS.mint,
|
|
39
|
+
O: COLORS.rose,
|
|
40
|
+
P: COLORS.violet,
|
|
41
|
+
I: COLORS.blue,
|
|
42
|
+
"-": COLORS.amber,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
interface Matrix {
|
|
46
|
+
rows: string[];
|
|
47
|
+
width: number;
|
|
48
|
+
totalCells: number;
|
|
49
|
+
ranks: Map<string, number>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function glyphFor(ch: string): string[] {
|
|
53
|
+
return FONT[ch.toUpperCase()] ?? FONT[" "];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function matrixForText(text: string): Matrix {
|
|
57
|
+
const rows = Array.from({ length: ROWS }, () => "");
|
|
58
|
+
for (const [index, raw] of Array.from(text).entries()) {
|
|
59
|
+
const glyph = glyphFor(raw);
|
|
60
|
+
for (let r = 0; r < ROWS; r++) {
|
|
61
|
+
rows[r] += (index === 0 ? "" : " ") + glyph[r];
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const cells: Array<{ row: number; col: number }> = [];
|
|
66
|
+
for (let row = 0; row < ROWS; row++) {
|
|
67
|
+
for (let col = 0; col < rows[row].length; col++) {
|
|
68
|
+
if (rows[row][col] !== " ") cells.push({ row, col });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Tetris reads as a pile building upward: bottom rows settle first.
|
|
73
|
+
cells.sort((a, b) => b.row - a.row || a.col - b.col);
|
|
74
|
+
|
|
75
|
+
const ranks = new Map<string, number>();
|
|
76
|
+
for (const [rank, cell] of cells.entries()) {
|
|
77
|
+
ranks.set(`${cell.row}:${cell.col}`, rank);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
rows,
|
|
82
|
+
width: rows[0]?.length ?? 0,
|
|
83
|
+
totalCells: cells.length,
|
|
84
|
+
ranks,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Lay a word out as six rows of ASCII-safe Tetris cells. */
|
|
89
|
+
export function bannerLines(text = "ZERO"): string[] {
|
|
90
|
+
return matrixForText(text).rows.map((row) =>
|
|
91
|
+
Array.from(row)
|
|
92
|
+
.map((cell) => (cell === " " ? " " : "[]"))
|
|
93
|
+
.join(""),
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function lerp(a: number, b: number, t: number): number {
|
|
98
|
+
return a + (b - a) * t;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function lerpColor(c1: RGB, c2: RGB, t: number): RGB {
|
|
102
|
+
const k = Math.max(0, Math.min(1, t));
|
|
103
|
+
return [
|
|
104
|
+
Math.round(lerp(c1[0], c2[0], k)),
|
|
105
|
+
Math.round(lerp(c1[1], c2[1], k)),
|
|
106
|
+
Math.round(lerp(c1[2], c2[2], k)),
|
|
107
|
+
];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function ansiFg([r, g, b]: RGB, text: string): string {
|
|
111
|
+
return `\x1b[38;2;${r};${g};${b}m${text}\x1b[0m`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function colorForPiece(piece: string, row: number, rank: number, active: boolean): RGB {
|
|
115
|
+
if (active) return COLORS.peak;
|
|
116
|
+
const base = PIECE_COLORS[piece] ?? COLORS.blue;
|
|
117
|
+
const heightWarmth = (ROWS - 1 - row) / Math.max(1, ROWS - 1);
|
|
118
|
+
const rankPulse = (rank % 7) / 18;
|
|
119
|
+
return lerpColor(base, COLORS.peak, heightWarmth * 0.18 + rankPulse);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function paintMatrixLine(matrix: Matrix, row: number, visibleCells: number): string {
|
|
123
|
+
let out = "";
|
|
124
|
+
const line = matrix.rows[row] ?? "";
|
|
125
|
+
for (let col = 0; col < matrix.width; col++) {
|
|
126
|
+
const piece = line[col] ?? " ";
|
|
127
|
+
if (piece === " ") {
|
|
128
|
+
out += " ";
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
const rank = matrix.ranks.get(`${row}:${col}`) ?? Number.POSITIVE_INFINITY;
|
|
132
|
+
if (rank >= visibleCells) {
|
|
133
|
+
out += " ";
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
out += ansiFg(colorForPiece(piece, row, rank, rank === visibleCells - 1), "[]");
|
|
137
|
+
}
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
interface OutStream {
|
|
142
|
+
write(chunk: string): unknown;
|
|
143
|
+
isTTY?: boolean;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Whether colour output is appropriate for the given stream. */
|
|
147
|
+
function shouldColor(stream: OutStream): boolean {
|
|
148
|
+
if (process.env.NO_COLOR) return false;
|
|
149
|
+
if (process.env.FORCE_COLOR) return true;
|
|
150
|
+
return Boolean(stream && stream.isTTY);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The three banner modes, resolved from the ZERO_BANNER environment variable. */
|
|
154
|
+
export type BannerMode = "off" | "static" | "shimmer";
|
|
155
|
+
|
|
156
|
+
/** Resolve the banner mode from an environment-variable value. */
|
|
157
|
+
export function resolveBannerMode(raw: string | undefined): BannerMode {
|
|
158
|
+
const value = (raw ?? "").trim().toLowerCase();
|
|
159
|
+
if (value === "off" || value === "0" || value === "false" || value === "none") return "off";
|
|
160
|
+
if (value === "static" || value === "plain" || value === "no-animate") return "static";
|
|
161
|
+
return "shimmer";
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Atomics.wait gives us a synchronous sleep without adding a dependency. The
|
|
165
|
+
// banner renders before Pi's TUI takes over, so blocking briefly is intentional.
|
|
166
|
+
const SLEEP_SLOT = new Int32Array(new SharedArrayBuffer(4));
|
|
167
|
+
function sleepSync(ms: number): void {
|
|
168
|
+
if (ms > 0) Atomics.wait(SLEEP_SLOT, 0, 0, ms);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function easeOutCubic(t: number): number {
|
|
172
|
+
return 1 - Math.pow(1 - t, 3);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Options for {@link renderBanner}. */
|
|
176
|
+
export interface RenderOptions {
|
|
177
|
+
mode?: BannerMode;
|
|
178
|
+
text?: string;
|
|
179
|
+
frames?: number;
|
|
180
|
+
frameMs?: number;
|
|
181
|
+
stream?: OutStream;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Render the ZERO banner synchronously.
|
|
186
|
+
*
|
|
187
|
+
* On a non-colour stream the banner is printed plain. Otherwise the animated
|
|
188
|
+
* mode assembles cells frame by frame, then settles on the completed wordmark.
|
|
189
|
+
*/
|
|
190
|
+
export function renderBanner(options: RenderOptions = {}): void {
|
|
191
|
+
const mode = options.mode ?? resolveBannerMode(process.env.ZERO_BANNER);
|
|
192
|
+
if (mode === "off") return;
|
|
193
|
+
|
|
194
|
+
const stream: OutStream = options.stream ?? process.stdout;
|
|
195
|
+
const matrix = matrixForText(options.text ?? "ZERO");
|
|
196
|
+
|
|
197
|
+
if (!shouldColor(stream)) {
|
|
198
|
+
stream.write("\n" + bannerLines(options.text ?? "ZERO").join("\n") + "\n\n");
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
stream.write("\n" + Array.from({ length: ROWS }, () => "").join("\n") + "\n");
|
|
203
|
+
|
|
204
|
+
if (mode === "shimmer") {
|
|
205
|
+
const frames = Math.max(2, options.frames ?? 42);
|
|
206
|
+
const frameMs = Math.max(1, options.frameMs ?? 18);
|
|
207
|
+
for (let f = 0; f < frames; f++) {
|
|
208
|
+
const t = easeOutCubic(f / (frames - 1));
|
|
209
|
+
const visibleCells = Math.max(1, Math.ceil(matrix.totalCells * t));
|
|
210
|
+
stream.write(`\x1b[${ROWS}A\r`);
|
|
211
|
+
for (let r = 0; r < ROWS; r++) {
|
|
212
|
+
stream.write("\x1b[2K" + paintMatrixLine(matrix, r, visibleCells) + "\n");
|
|
213
|
+
}
|
|
214
|
+
sleepSync(frameMs);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
stream.write(`\x1b[${ROWS}A\r`);
|
|
219
|
+
for (let r = 0; r < ROWS; r++) {
|
|
220
|
+
stream.write("\x1b[2K" + paintMatrixLine(matrix, r, matrix.totalCells) + "\n");
|
|
221
|
+
}
|
|
222
|
+
stream.write("\n");
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* The pi extension entry point.
|
|
227
|
+
*
|
|
228
|
+
* The pi argument is accepted for API compatibility. The banner is rendered
|
|
229
|
+
* defensively: visual sugar should never break an interactive session.
|
|
230
|
+
*/
|
|
231
|
+
export default function register(_pi?: unknown): void {
|
|
232
|
+
try {
|
|
233
|
+
renderBanner();
|
|
234
|
+
} catch {
|
|
235
|
+
// A banner failure must never break a pi session.
|
|
236
|
+
}
|
|
237
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// zero-pi — Windows process-tree kill for aborts.
|
|
2
|
+
//
|
|
3
|
+
// On Windows, `ChildProcess.kill()` terminates only the target process, not
|
|
4
|
+
// its descendants. A provider like pi-claude-cli spawns `claude` through a
|
|
5
|
+
// `cmd.exe` batch wrapper (`claude` resolves to `claude.cmd`), so when pi
|
|
6
|
+
// aborts a turn the wrapper is killed but the real `claude` process is
|
|
7
|
+
// orphaned and keeps streaming — pressing Esc appears to do nothing.
|
|
8
|
+
//
|
|
9
|
+
// This extension patches `child_process.spawn` once, at load, so every
|
|
10
|
+
// subprocess spawned afterwards gets a `kill()` that terminates the whole
|
|
11
|
+
// process tree via `taskkill /T /F`. It is a no-op on non-Windows platforms.
|
|
12
|
+
//
|
|
13
|
+
// Patching the shared `child_process` module reaches code in other packages
|
|
14
|
+
// (pi-claude-cli, and the `cross-spawn` it depends on, both call into the same
|
|
15
|
+
// builtin) without modifying them — so the fix survives `pi update`.
|
|
16
|
+
|
|
17
|
+
import { createRequire } from "node:module";
|
|
18
|
+
|
|
19
|
+
/** Build the Windows command that kills a process and its whole tree. */
|
|
20
|
+
export function treeKillCommand(pid: number): string {
|
|
21
|
+
return `taskkill /pid ${pid} /t /f`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The slice of a child process this extension touches. */
|
|
25
|
+
export interface KillableChild {
|
|
26
|
+
pid?: number;
|
|
27
|
+
kill(signal?: NodeJS.Signals | number): boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Marker so a `kill` is wrapped at most once. */
|
|
31
|
+
const WRAPPED = Symbol.for("zero-pi.win-tree-kill.wrapped");
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Replace `child.kill` with one that terminates the whole process tree by
|
|
35
|
+
* running `exec(treeKillCommand(pid))`. Falls back to the original `kill` when
|
|
36
|
+
* there is no pid or the tree-kill throws. Idempotent — wrapping an already
|
|
37
|
+
* wrapped child is a no-op. Exported for tests.
|
|
38
|
+
*/
|
|
39
|
+
export function wrapKill(
|
|
40
|
+
child: KillableChild,
|
|
41
|
+
exec: (command: string) => void,
|
|
42
|
+
): KillableChild {
|
|
43
|
+
const original = child.kill;
|
|
44
|
+
if (typeof original !== "function") return child;
|
|
45
|
+
|
|
46
|
+
const tagged = original as typeof original & { [WRAPPED]?: boolean };
|
|
47
|
+
if (tagged[WRAPPED]) return child;
|
|
48
|
+
|
|
49
|
+
const wrapped = function (signal?: NodeJS.Signals | number): boolean {
|
|
50
|
+
const pid = child.pid;
|
|
51
|
+
if (typeof pid === "number") {
|
|
52
|
+
try {
|
|
53
|
+
exec(treeKillCommand(pid));
|
|
54
|
+
return true;
|
|
55
|
+
} catch {
|
|
56
|
+
// Process already gone, or taskkill unavailable — fall through.
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return original.call(child, signal);
|
|
60
|
+
} as KillableChild["kill"] & { [WRAPPED]?: boolean };
|
|
61
|
+
wrapped[WRAPPED] = true;
|
|
62
|
+
|
|
63
|
+
child.kill = wrapped;
|
|
64
|
+
return child;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Whether the running platform needs the tree-kill patch. */
|
|
68
|
+
export function shouldPatch(platform: string): boolean {
|
|
69
|
+
return platform === "win32";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Module-level guard so the global patch is installed at most once. */
|
|
73
|
+
let patched = false;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The pi extension entry point. Patches `child_process.spawn` so every later
|
|
77
|
+
* subprocess tree-kills on `kill()`. Defensive: a failure here must never
|
|
78
|
+
* break a pi session, so it is swallowed.
|
|
79
|
+
*/
|
|
80
|
+
export default function register(): void {
|
|
81
|
+
if (patched || !shouldPatch(process.platform)) return;
|
|
82
|
+
try {
|
|
83
|
+
const require = createRequire(import.meta.url);
|
|
84
|
+
const cp = require("node:child_process") as {
|
|
85
|
+
spawn: (...args: unknown[]) => KillableChild;
|
|
86
|
+
execSync: (command: string, options?: unknown) => unknown;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const originalSpawn = cp.spawn as typeof cp.spawn & { [WRAPPED]?: boolean };
|
|
90
|
+
if (originalSpawn[WRAPPED]) {
|
|
91
|
+
patched = true;
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const exec = (command: string): void => {
|
|
96
|
+
cp.execSync(command, { stdio: "ignore" });
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const patchedSpawn = function (this: unknown, ...args: unknown[]): KillableChild {
|
|
100
|
+
const child = originalSpawn.apply(this, args);
|
|
101
|
+
try {
|
|
102
|
+
return wrapKill(child, exec);
|
|
103
|
+
} catch {
|
|
104
|
+
return child;
|
|
105
|
+
}
|
|
106
|
+
} as typeof cp.spawn & { [WRAPPED]?: boolean };
|
|
107
|
+
patchedSpawn[WRAPPED] = true;
|
|
108
|
+
|
|
109
|
+
cp.spawn = patchedSpawn;
|
|
110
|
+
patched = true;
|
|
111
|
+
} catch {
|
|
112
|
+
// Hardening must never break a session.
|
|
113
|
+
}
|
|
114
|
+
}
|