killeros 2.0.21 → 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,18 @@ 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
+
7
19
  ## [2.0.21] - 2026-08-29
8
20
 
9
21
  ### Changed
package/README.md CHANGED
@@ -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.21`. 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
 
@@ -12,7 +12,13 @@ const GIT_STATUS_REFRESH_INTERVAL_MS = 30_000;
12
12
  const CODEX_PROVIDER = "openai-codex";
13
13
  const colorDirectory = (text: string): string => `\x1B[38;2;240;248;154m${text}\x1B[39m`;
14
14
 
15
- function resolveUncommittedFileCount(cwd: string): Promise<number | undefined> {
15
+ interface GitFileChanges {
16
+ modified: number;
17
+ added: number;
18
+ deleted: number;
19
+ }
20
+
21
+ function resolveGitFileChanges(cwd: string): Promise<GitFileChanges | undefined> {
16
22
  return new Promise((resolve) => {
17
23
  execFile(
18
24
  "git",
@@ -29,25 +35,32 @@ function resolveUncommittedFileCount(cwd: string): Promise<number | undefined> {
29
35
  return;
30
36
  }
31
37
 
38
+ const changes: GitFileChanges = { modified: 0, added: 0, deleted: 0 };
32
39
  const entries = stdout.split("\0");
33
- let count = 0;
34
40
  for (let index = 0; index < entries.length; index += 1) {
35
41
  const entry = entries[index];
36
42
  if (!entry) continue;
37
- count += 1;
38
- if (entry[0] === "R" || entry[0] === "C" || entry[1] === "R" || entry[1] === "C") index += 1;
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;
39
48
  }
40
- resolve(count);
49
+ resolve(changes);
41
50
  },
42
51
  );
43
52
  });
44
53
  }
45
54
 
46
- /** Coalesces Git status requests to one active scan and one queued follow-up. */
47
- export function createGitStatusRefresh(
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>(
48
61
  cwd: string,
49
- onCount: (count: number | undefined) => void,
50
- resolveCount: (cwd: string) => Promise<number | undefined> = resolveUncommittedFileCount,
62
+ onResult: (result: T | undefined) => void,
63
+ resolveResult: (cwd: string) => Promise<T | undefined>,
51
64
  ): { request: () => void; dispose: () => void } {
52
65
  let disposed = false;
53
66
  let pending = false;
@@ -59,8 +72,8 @@ export function createGitStatusRefresh(
59
72
  return;
60
73
  }
61
74
  pending = true;
62
- void resolveCount(cwd).then((count) => {
63
- if (!disposed) onCount(count);
75
+ void resolveResult(cwd).then((result) => {
76
+ if (!disposed) onResult(result);
64
77
  }).finally(() => {
65
78
  pending = false;
66
79
  if (!disposed && queued) {
@@ -78,6 +91,22 @@ export function createGitStatusRefresh(
78
91
  };
79
92
  }
80
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
+
81
110
  type ScheduleFallback = (refresh: () => void, intervalMs: number) => () => void;
82
111
 
83
112
  const scheduleFallback: ScheduleFallback = (refresh, intervalMs) => {
@@ -236,6 +265,12 @@ function formatGoalFooter(state: GoalState | undefined, theme: Theme): string {
236
265
  return "";
237
266
  }
238
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
+
239
274
  export function registerFooter(pi: ExtensionAPI, goalRuntime: GoalRuntime): void {
240
275
  let currentModel: ExtensionContext["model"];
241
276
  let thinkingLevel: ThinkingLevel = "off";
@@ -273,10 +308,10 @@ export function registerFooter(pi: ExtensionAPI, goalRuntime: GoalRuntime): void
273
308
 
274
309
  ctx.ui.setFooter((tui, theme, footerData) => {
275
310
  activeTui = tui;
276
- let uncommittedFileCount: number | undefined;
277
- const gitStatus = createGitStatusRefresh(ctx.cwd, (count) => {
278
- if (count === uncommittedFileCount) return;
279
- uncommittedFileCount = count;
311
+ let gitFileChanges: GitFileChanges | undefined;
312
+ const gitStatus = createGitFileChangesRefresh(ctx.cwd, (changes) => {
313
+ if (JSON.stringify(changes) === JSON.stringify(gitFileChanges)) return;
314
+ gitFileChanges = changes;
280
315
  tui.requestRender();
281
316
  });
282
317
  requestGitStatusRefresh = gitStatus.request;
@@ -332,10 +367,9 @@ export function registerFooter(pi: ExtensionAPI, goalRuntime: GoalRuntime): void
332
367
  : footerRowFits(primaryFocused, "", width)
333
368
  ? renderFooterRow(primaryFocused, "", width)
334
369
  : renderFooterRow(essentialModel, context, width);
370
+ const changes = gitFileChanges ? formatGitFileChanges(gitFileChanges, theme) : "";
335
371
  const branchLabel = branch
336
- ? uncommittedFileCount
337
- ? `${theme.fg("dim", `${branch} · `)}${theme.fg("warning", `${uncommittedFileCount} changed`)}`
338
- : theme.fg("dim", branch)
372
+ ? `${theme.fg("dim", branch)}${changes ? `${theme.fg("dim", " · ")}${changes}` : ""}`
339
373
  : "";
340
374
  const workspaceRight = goal || fullDirectory;
341
375
  const secondaryRow = footerRowFits(branchLabel, workspaceRight, width)
package/killeros/hooks.ts CHANGED
@@ -197,16 +197,27 @@ function appendBounded(output: HookOutputBuffer, chunk: Buffer | string): void {
197
197
  output.text += output.decoder.write(captured);
198
198
  }
199
199
 
200
+ /** Terminates a Windows hook tree without depending on the caller's PATH. */
201
+ function terminateWindowsHookTree(child: HookChildProcess): Promise<boolean> {
202
+ return new Promise((resolve) => {
203
+ try {
204
+ const taskkill = process.env.SystemRoot
205
+ ? path.join(process.env.SystemRoot, "System32", "taskkill.exe")
206
+ : "taskkill";
207
+ const killer = spawn(taskkill, ["/pid", String(child.pid), "/T", "/F"], {
208
+ shell: false,
209
+ stdio: "ignore",
210
+ windowsHide: true,
211
+ });
212
+ killer.once("error", () => resolve(false));
213
+ killer.once("close", (code) => resolve(code === 0));
214
+ } catch {
215
+ resolve(false);
216
+ }
217
+ });
218
+ }
219
+
200
220
  function terminateHookProcess(child: HookChildProcess, force: boolean): void {
201
- if (process.platform === "win32" && force && child.pid) {
202
- const killer = spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], {
203
- shell: false,
204
- stdio: "ignore",
205
- windowsHide: true,
206
- });
207
- killer.unref();
208
- return;
209
- }
210
221
  if (process.platform !== "win32" && child.pid) {
211
222
  try {
212
223
  process.kill(-child.pid, force ? "SIGKILL" : "SIGTERM");
@@ -282,6 +293,7 @@ export function executeHook(
282
293
  let timer: NodeJS.Timeout | undefined;
283
294
  let forceTimer: NodeJS.Timeout | undefined;
284
295
  let settleTimer: NodeJS.Timeout | undefined;
296
+ let windowsCleanupPending = false;
285
297
  const finish = (code: number, exitUnconfirmed = false): void => {
286
298
  if (completed) return;
287
299
  completed = true;
@@ -304,6 +316,21 @@ export function executeHook(
304
316
  const beginTermination = (reason: "timeout" | "cancelled"): void => {
305
317
  if (completed || termination) return;
306
318
  termination = reason;
319
+ if (process.platform === "win32" && child.pid) {
320
+ windowsCleanupPending = true;
321
+ void terminateWindowsHookTree(child).then((confirmed) => {
322
+ if (completed) return;
323
+ if (!confirmed) terminateHookProcess(child, true);
324
+ finish(terminationCode(), !confirmed);
325
+ windowsCleanupPending = false;
326
+ });
327
+ settleTimer = setTimeout(() => {
328
+ terminateHookProcess(child, true);
329
+ finish(terminationCode(), true);
330
+ windowsCleanupPending = false;
331
+ }, 2_000);
332
+ return;
333
+ }
307
334
  terminateHookProcess(child, false);
308
335
  forceTimer = setTimeout(() => {
309
336
  if (completed) return;
@@ -318,9 +345,11 @@ export function executeHook(
318
345
  child.stderr.on("data", (chunk) => appendBounded(stderr, chunk));
319
346
  child.on("error", (error) => {
320
347
  appendBounded(stderr, error.message);
321
- finish(termination ? terminationCode() : 1);
348
+ if (!windowsCleanupPending) finish(termination ? terminationCode() : 1);
349
+ });
350
+ child.once("close", (code) => {
351
+ if (!windowsCleanupPending) finish(termination ? terminationCode() : code ?? 1);
322
352
  });
323
- child.once("close", (code) => finish(termination ? terminationCode() : code ?? 1));
324
353
  timer = setTimeout(() => beginTermination("timeout"), Math.max(1, Math.min(timeoutMs, HOOK_TIMEOUT_MAX_MS)));
325
354
  });
326
355
  }
@@ -11,7 +11,12 @@ const PERSONAL_INSTRUCTIONS_LIMIT = 32 * 1024;
11
11
  function readBoundedText(filePath: string, limit = PERSONAL_INSTRUCTIONS_LIMIT): string | undefined {
12
12
  let descriptor: number | undefined;
13
13
  try {
14
+ const pathStat = lstatSync(filePath);
15
+ if (!pathStat.isFile() || pathStat.isSymbolicLink() || pathStat.nlink !== 1) return undefined;
14
16
  descriptor = openSync(filePath, "r");
17
+ const openedStat = fstatSync(descriptor);
18
+ if (!openedStat.isFile() || openedStat.nlink !== 1
19
+ || openedStat.dev !== pathStat.dev || openedStat.ino !== pathStat.ino) return undefined;
15
20
  const buffer = Buffer.alloc(limit + 1);
16
21
  const bytesRead = readSync(descriptor, buffer, 0, buffer.length, 0);
17
22
  const decoder = new StringDecoder("utf8");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "killeros",
3
- "version": "2.0.21",
3
+ "version": "2.0.22",
4
4
  "description": "TUI, goals, and workflow automation for the Pi coding agent",
5
5
  "type": "module",
6
6
  "keywords": [