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 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.20`. Add `-l` to install only for the current project. Restart Pi after installing.
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
 
@@ -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
- const minutes = Math.floor(totalSeconds / 60);
32
- if (minutes < 60) return `${minutes}m`;
33
- return `${Math.floor(minutes / 60)}h${minutes % 60}m`;
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 {
@@ -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 "./goals.ts";
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 FOOTER_REFRESH_INTERVAL_MS = 1_000;
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 (${formatGoalElapsed(goalElapsedMilliseconds(state))})`);
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
- const unsubscribe = footerData.onBranchChange(() => tui.requestRender());
205
- const refreshTimer = setInterval(() => tui.requestRender(), FOOTER_REFRESH_INTERVAL_MS);
206
- refreshTimer.unref?.();
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
- clearInterval(refreshTimer);
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 = theme.fg("dim", cwd);
231
- const focusedDirectory = theme.fg("dim", compactDirectory(cwd));
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 branchLabel = branch ? theme.fg("dim", branch) : "";
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
- pi.on("turn_end", invalidateSessionCost);
275
- pi.on("session_compact", invalidateSessionCost);
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
  }