killeros 2.0.20 → 2.0.22
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/CHANGELOG.md +29 -0
- package/README.md +2 -2
- package/killeros/display.ts +8 -3
- package/killeros/footer.ts +152 -25
- package/killeros/goal-state.ts +407 -0
- package/killeros/goals.ts +21 -375
- package/killeros/handoff.ts +39 -16
- package/killeros/hooks.ts +40 -11
- package/killeros/init-evidence.ts +15 -3
- package/killeros/personal-instructions.ts +62 -5
- package/killeros/question-ui.ts +590 -0
- package/killeros/question.ts +12 -558
- package/killeros/secret-detector.ts +19 -0
- package/killeros/shell-ui.ts +1 -1
- package/package.json +5 -5
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,35 @@ All notable changes to KillerOS are documented here.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [2.0.22] - 2026-08-30
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- Replaced the footer's changed-file total with a colored modified, added, and deleted breakdown.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- Preserved the existing `createGitStatusRefresh` changed-file count callback contract.
|
|
16
|
+
- Rejected linked `AGENTS.local.md` files before reading personal instructions.
|
|
17
|
+
- Confirmed Windows hook process-tree cleanup before settling cancellations and timeouts, even when `taskkill` is absent from `PATH`.
|
|
18
|
+
|
|
19
|
+
## [2.0.21] - 2026-08-29
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- Bounded goal verification and Pi compatibility, reduced Git status polling, reserved handoff context, and separated goal state and question UI logic.
|
|
24
|
+
- Blocked moderate dependency advisories in CI and updated TypeBox within the supported 1.x line.
|
|
25
|
+
- Preserved seconds in footer and goal elapsed times after one minute.
|
|
26
|
+
- Added a muted top border to the prompt editor.
|
|
27
|
+
- Colored the footer workspace path `#F0F89A`.
|
|
28
|
+
- Added a live changed-file count beside the Git branch when the worktree is dirty.
|
|
29
|
+
- Synced `dev` back to successful `main` releases after publishing.
|
|
30
|
+
|
|
31
|
+
### Fixed
|
|
32
|
+
|
|
33
|
+
- Kept likely secrets out of `/init`, restricted personal-instruction imports to Pi's agent directory, and replaced injectable handoff framing with validated JSON.
|
|
34
|
+
- Hardened releases against stale CI runs, mismatched existing npm artifacts, and tag conflicts discovered after publication.
|
|
35
|
+
|
|
7
36
|
## [2.0.20] - 2026-08-25
|
|
8
37
|
|
|
9
38
|
### Changed
|
package/README.md
CHANGED
|
@@ -19,7 +19,7 @@ A TypeScript extension for the [Pi coding agent](https://github.com/earendil-wor
|
|
|
19
19
|
## Requirements
|
|
20
20
|
|
|
21
21
|
- Node.js 22.19.0+
|
|
22
|
-
- Pi 0.84.3
|
|
22
|
+
- Pi 0.84.3 or later within the 0.x release line
|
|
23
23
|
- An interactive TUI session for the custom header, editor, footer, `question`, and `/init`
|
|
24
24
|
|
|
25
25
|
## Install
|
|
@@ -34,7 +34,7 @@ Or from GitHub:
|
|
|
34
34
|
pi install git:github.com/KyrosHendrix/pi-KillerOS
|
|
35
35
|
```
|
|
36
36
|
|
|
37
|
-
Pin a release by appending its tag, for example `@v2.0.
|
|
37
|
+
Pin a release by appending its tag, for example `@v2.0.22`. Add `-l` to install only for the current project. Restart Pi after installing.
|
|
38
38
|
|
|
39
39
|
## Commands
|
|
40
40
|
|
package/killeros/display.ts
CHANGED
|
@@ -28,9 +28,14 @@ export function formatTime(milliseconds: number): string {
|
|
|
28
28
|
if (!Number.isFinite(milliseconds)) return "0s";
|
|
29
29
|
const totalSeconds = Math.max(0, Math.floor(milliseconds / 1_000));
|
|
30
30
|
if (totalSeconds < 60) return `${totalSeconds}s`;
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
31
|
+
|
|
32
|
+
const seconds = totalSeconds % 60;
|
|
33
|
+
const totalMinutes = Math.floor(totalSeconds / 60);
|
|
34
|
+
if (totalMinutes < 60) return `${totalMinutes}m ${seconds.toString().padStart(2, "0")}s`;
|
|
35
|
+
|
|
36
|
+
const hours = Math.floor(totalMinutes / 60);
|
|
37
|
+
const minutes = totalMinutes % 60;
|
|
38
|
+
return `${hours}h ${minutes.toString().padStart(2, "0")}m ${seconds.toString().padStart(2, "0")}s`;
|
|
34
39
|
}
|
|
35
40
|
|
|
36
41
|
export function formatTokens(value: number): string {
|
package/killeros/footer.ts
CHANGED
|
@@ -1,14 +1,127 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
1
2
|
import { type ExtensionAPI, type ExtensionContext, type Theme, type ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
2
3
|
import { truncateToWidth, visibleWidth, type TUI } from "@earendil-works/pi-tui";
|
|
3
4
|
import { isCodexFastEnabled, subscribeCodexFast } from "./codex-fast-state.ts";
|
|
4
5
|
import { formatCwd, formatTime, formatTokens, padRight } from "./display.ts";
|
|
5
|
-
import { goalElapsedMilliseconds } from "./
|
|
6
|
+
import { goalElapsedMilliseconds } from "./goal-state.ts";
|
|
6
7
|
import type { GoalRuntime, GoalState } from "./runtime.ts";
|
|
7
8
|
import { safeTerminalText } from "./safe-terminal-text.ts";
|
|
8
9
|
import { LEVEL_COLORS, type ThinkingLevel } from "./variants.ts";
|
|
9
10
|
|
|
10
|
-
const
|
|
11
|
+
const GIT_STATUS_REFRESH_INTERVAL_MS = 30_000;
|
|
11
12
|
const CODEX_PROVIDER = "openai-codex";
|
|
13
|
+
const colorDirectory = (text: string): string => `\x1B[38;2;240;248;154m${text}\x1B[39m`;
|
|
14
|
+
|
|
15
|
+
interface GitFileChanges {
|
|
16
|
+
modified: number;
|
|
17
|
+
added: number;
|
|
18
|
+
deleted: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function resolveGitFileChanges(cwd: string): Promise<GitFileChanges | undefined> {
|
|
22
|
+
return new Promise((resolve) => {
|
|
23
|
+
execFile(
|
|
24
|
+
"git",
|
|
25
|
+
["-C", cwd, "status", "--porcelain=v1", "-z", "--untracked-files=all"],
|
|
26
|
+
{
|
|
27
|
+
encoding: "utf8",
|
|
28
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
29
|
+
timeout: 1_000,
|
|
30
|
+
windowsHide: true,
|
|
31
|
+
},
|
|
32
|
+
(error, stdout) => {
|
|
33
|
+
if (error) {
|
|
34
|
+
resolve(undefined);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const changes: GitFileChanges = { modified: 0, added: 0, deleted: 0 };
|
|
39
|
+
const entries = stdout.split("\0");
|
|
40
|
+
for (let index = 0; index < entries.length; index += 1) {
|
|
41
|
+
const entry = entries[index];
|
|
42
|
+
if (!entry) continue;
|
|
43
|
+
const status = entry.slice(0, 2);
|
|
44
|
+
if (status.includes("D")) changes.deleted += 1;
|
|
45
|
+
else if (status === "??" || status.includes("A")) changes.added += 1;
|
|
46
|
+
else changes.modified += 1;
|
|
47
|
+
if (status.includes("R") || status.includes("C")) index += 1;
|
|
48
|
+
}
|
|
49
|
+
resolve(changes);
|
|
50
|
+
},
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function resolveUncommittedFileCount(cwd: string): Promise<number | undefined> {
|
|
56
|
+
const changes = await resolveGitFileChanges(cwd);
|
|
57
|
+
return changes && changes.modified + changes.added + changes.deleted;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function createGitRefresh<T>(
|
|
61
|
+
cwd: string,
|
|
62
|
+
onResult: (result: T | undefined) => void,
|
|
63
|
+
resolveResult: (cwd: string) => Promise<T | undefined>,
|
|
64
|
+
): { request: () => void; dispose: () => void } {
|
|
65
|
+
let disposed = false;
|
|
66
|
+
let pending = false;
|
|
67
|
+
let queued = false;
|
|
68
|
+
const request = (): void => {
|
|
69
|
+
if (disposed) return;
|
|
70
|
+
if (pending) {
|
|
71
|
+
queued = true;
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
pending = true;
|
|
75
|
+
void resolveResult(cwd).then((result) => {
|
|
76
|
+
if (!disposed) onResult(result);
|
|
77
|
+
}).finally(() => {
|
|
78
|
+
pending = false;
|
|
79
|
+
if (!disposed && queued) {
|
|
80
|
+
queued = false;
|
|
81
|
+
request();
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
};
|
|
85
|
+
return {
|
|
86
|
+
request,
|
|
87
|
+
dispose() {
|
|
88
|
+
disposed = true;
|
|
89
|
+
queued = false;
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Coalesces Git status requests to one active scan and one queued follow-up. */
|
|
95
|
+
export function createGitStatusRefresh(
|
|
96
|
+
cwd: string,
|
|
97
|
+
onCount: (count: number | undefined) => void,
|
|
98
|
+
resolveCount: (cwd: string) => Promise<number | undefined> = resolveUncommittedFileCount,
|
|
99
|
+
): { request: () => void; dispose: () => void } {
|
|
100
|
+
return createGitRefresh(cwd, onCount, resolveCount);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function createGitFileChangesRefresh(
|
|
104
|
+
cwd: string,
|
|
105
|
+
onChanges: (changes: GitFileChanges | undefined) => void,
|
|
106
|
+
): { request: () => void; dispose: () => void } {
|
|
107
|
+
return createGitRefresh(cwd, onChanges, resolveGitFileChanges);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
type ScheduleFallback = (refresh: () => void, intervalMs: number) => () => void;
|
|
111
|
+
|
|
112
|
+
const scheduleFallback: ScheduleFallback = (refresh, intervalMs) => {
|
|
113
|
+
const timer = setInterval(refresh, intervalMs);
|
|
114
|
+
timer.unref?.();
|
|
115
|
+
return () => clearInterval(timer);
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/** Schedules the fallback Git scan independently from footer rendering. */
|
|
119
|
+
export function scheduleGitStatusFallback(
|
|
120
|
+
refresh: () => void,
|
|
121
|
+
schedule: ScheduleFallback = scheduleFallback,
|
|
122
|
+
): () => void {
|
|
123
|
+
return schedule(refresh, GIT_STATUS_REFRESH_INTERVAL_MS);
|
|
124
|
+
}
|
|
12
125
|
|
|
13
126
|
export function formatCost(usd: number): string {
|
|
14
127
|
if (!Number.isFinite(usd)) return "$—";
|
|
@@ -142,29 +255,22 @@ function renderFooter(rows: string[], width: number, theme: Theme): string[] {
|
|
|
142
255
|
return [theme.fg("borderMuted", "─".repeat(width)), ...rows];
|
|
143
256
|
}
|
|
144
257
|
|
|
145
|
-
function formatGoalElapsed(milliseconds: number): string {
|
|
146
|
-
const totalSeconds = Number.isFinite(milliseconds) ? Math.max(0, Math.floor(milliseconds / 1_000)) : 0;
|
|
147
|
-
if (totalSeconds < 60) return `${totalSeconds}s`;
|
|
148
|
-
|
|
149
|
-
const seconds = totalSeconds % 60;
|
|
150
|
-
const totalMinutes = Math.floor(totalSeconds / 60);
|
|
151
|
-
if (totalMinutes < 60) return `${totalMinutes}m ${seconds.toString().padStart(2, "0")}s`;
|
|
152
|
-
|
|
153
|
-
const hours = Math.floor(totalMinutes / 60);
|
|
154
|
-
const minutes = totalMinutes % 60;
|
|
155
|
-
return `${hours}h ${minutes.toString().padStart(2, "0")}m ${seconds.toString().padStart(2, "0")}s`;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
258
|
function formatGoalFooter(state: GoalState | undefined, theme: Theme): string {
|
|
159
259
|
if (!state) return "";
|
|
160
260
|
if (state.status === "active") {
|
|
161
|
-
return theme.fg("warning", `/goal is active (${
|
|
261
|
+
return theme.fg("warning", `/goal is active (${formatTime(goalElapsedMilliseconds(state, Date.now()))})`);
|
|
162
262
|
}
|
|
163
263
|
if (state.status === "paused") return theme.fg("warning", "/goal is paused");
|
|
164
264
|
if (state.status === "blocked") return theme.fg("error", "/goal is blocked");
|
|
165
265
|
return "";
|
|
166
266
|
}
|
|
167
267
|
|
|
268
|
+
function formatGitFileChanges(changes: GitFileChanges, theme: Theme): string {
|
|
269
|
+
const total = changes.modified + changes.added + changes.deleted;
|
|
270
|
+
if (total === 0) return "";
|
|
271
|
+
return `${theme.fg("dim", `±${total} [`)}${theme.fg("warning", `~${changes.modified}`)} ${theme.fg("success", `+${changes.added}`)} ${theme.fg("error", `−${changes.deleted}`)}${theme.fg("dim", "]")}`;
|
|
272
|
+
}
|
|
273
|
+
|
|
168
274
|
export function registerFooter(pi: ExtensionAPI, goalRuntime: GoalRuntime): void {
|
|
169
275
|
let currentModel: ExtensionContext["model"];
|
|
170
276
|
let thinkingLevel: ThinkingLevel = "off";
|
|
@@ -172,6 +278,7 @@ export function registerFooter(pi: ExtensionAPI, goalRuntime: GoalRuntime): void
|
|
|
172
278
|
let cachedSessionCost = 0;
|
|
173
279
|
let sessionCostDirty = true;
|
|
174
280
|
let unsubscribeCodexFast: (() => void) | undefined;
|
|
281
|
+
let requestGitStatusRefresh: (() => void) | undefined;
|
|
175
282
|
const resetSessionCost = (): void => {
|
|
176
283
|
cachedSessionCost = 0;
|
|
177
284
|
sessionCostDirty = true;
|
|
@@ -201,13 +308,25 @@ export function registerFooter(pi: ExtensionAPI, goalRuntime: GoalRuntime): void
|
|
|
201
308
|
|
|
202
309
|
ctx.ui.setFooter((tui, theme, footerData) => {
|
|
203
310
|
activeTui = tui;
|
|
204
|
-
|
|
205
|
-
const
|
|
206
|
-
|
|
311
|
+
let gitFileChanges: GitFileChanges | undefined;
|
|
312
|
+
const gitStatus = createGitFileChangesRefresh(ctx.cwd, (changes) => {
|
|
313
|
+
if (JSON.stringify(changes) === JSON.stringify(gitFileChanges)) return;
|
|
314
|
+
gitFileChanges = changes;
|
|
315
|
+
tui.requestRender();
|
|
316
|
+
});
|
|
317
|
+
requestGitStatusRefresh = gitStatus.request;
|
|
318
|
+
const unsubscribe = footerData.onBranchChange(() => {
|
|
319
|
+
gitStatus.request();
|
|
320
|
+
tui.requestRender();
|
|
321
|
+
});
|
|
322
|
+
gitStatus.request();
|
|
323
|
+
const stopFallback = scheduleGitStatusFallback(gitStatus.request);
|
|
207
324
|
return {
|
|
208
325
|
dispose() {
|
|
209
326
|
unsubscribe();
|
|
210
|
-
|
|
327
|
+
stopFallback();
|
|
328
|
+
gitStatus.dispose();
|
|
329
|
+
if (requestGitStatusRefresh === gitStatus.request) requestGitStatusRefresh = undefined;
|
|
211
330
|
if (activeTui === tui) activeTui = undefined;
|
|
212
331
|
},
|
|
213
332
|
invalidate() {},
|
|
@@ -227,8 +346,8 @@ export function registerFooter(pi: ExtensionAPI, goalRuntime: GoalRuntime): void
|
|
|
227
346
|
const context = formatContextProgress(usage?.tokens ?? null, contextWindow, theme);
|
|
228
347
|
const branch = footerData.getGitBranch();
|
|
229
348
|
const signature = formatModel(model, theme, true, isCodexFastEnabled());
|
|
230
|
-
const fullDirectory =
|
|
231
|
-
const focusedDirectory =
|
|
349
|
+
const fullDirectory = colorDirectory(cwd);
|
|
350
|
+
const focusedDirectory = colorDirectory(compactDirectory(cwd));
|
|
232
351
|
const goal = formatGoalFooter(goalRuntime.state, theme);
|
|
233
352
|
const primary = joinFooterParts([
|
|
234
353
|
signature,
|
|
@@ -248,7 +367,10 @@ export function registerFooter(pi: ExtensionAPI, goalRuntime: GoalRuntime): void
|
|
|
248
367
|
: footerRowFits(primaryFocused, "", width)
|
|
249
368
|
? renderFooterRow(primaryFocused, "", width)
|
|
250
369
|
: renderFooterRow(essentialModel, context, width);
|
|
251
|
-
const
|
|
370
|
+
const changes = gitFileChanges ? formatGitFileChanges(gitFileChanges, theme) : "";
|
|
371
|
+
const branchLabel = branch
|
|
372
|
+
? `${theme.fg("dim", branch)}${changes ? `${theme.fg("dim", " · ")}${changes}` : ""}`
|
|
373
|
+
: "";
|
|
252
374
|
const workspaceRight = goal || fullDirectory;
|
|
253
375
|
const secondaryRow = footerRowFits(branchLabel, workspaceRight, width)
|
|
254
376
|
? renderFooterRow(branchLabel, workspaceRight, width)
|
|
@@ -271,8 +393,12 @@ export function registerFooter(pi: ExtensionAPI, goalRuntime: GoalRuntime): void
|
|
|
271
393
|
thinkingLevel = event.level;
|
|
272
394
|
activeTui?.requestRender();
|
|
273
395
|
});
|
|
274
|
-
|
|
275
|
-
|
|
396
|
+
const refreshAfterActivity = (): void => {
|
|
397
|
+
invalidateSessionCost();
|
|
398
|
+
requestGitStatusRefresh?.();
|
|
399
|
+
};
|
|
400
|
+
pi.on("turn_end", refreshAfterActivity);
|
|
401
|
+
pi.on("session_compact", refreshAfterActivity);
|
|
276
402
|
pi.on("session_tree", () => {
|
|
277
403
|
resetSessionCost();
|
|
278
404
|
activeTui?.requestRender();
|
|
@@ -282,6 +408,7 @@ export function registerFooter(pi: ExtensionAPI, goalRuntime: GoalRuntime): void
|
|
|
282
408
|
unsubscribeCodexFast = undefined;
|
|
283
409
|
resetSessionCost();
|
|
284
410
|
activeTui = undefined;
|
|
411
|
+
requestGitStatusRefresh = undefined;
|
|
285
412
|
goalRuntime.requestRender = undefined;
|
|
286
413
|
});
|
|
287
414
|
}
|