killeros 2.1.28 → 2.1.29

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,20 @@ All notable changes to KillerOS are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [2.1.29] - 2026-09-19
8
+
9
+ ### Added
10
+
11
+ - Added `/auto-compact [status|on|off|<percent>]` to inspect and update the global automatic compaction preference without restarting Pi.
12
+
13
+ ### Fixed
14
+
15
+ - Kept normalized CRLF checkouts clean in passive Git scans while respecting text attributes and binary files without running filters.
16
+ - Detected same-size tracked-file rewrites even when tools restore the indexed modification time.
17
+ - Resolved change-receipt repository ownership at the start of every response so nested or replaced repositories cannot reuse a stale monitor.
18
+ - Kept the 100% automatic-compaction threshold active for later user requests and goal turns without compacting the generated ordinary continuation.
19
+ - Prevented automatic footer and change-receipt scans from starting clean or process filters, including filters added and removed while a scan is running.
20
+
7
21
  ## [2.1.28] - 2026-09-15
8
22
 
9
23
  ### Changed
package/README.md CHANGED
@@ -8,7 +8,7 @@ A TypeScript extension for the [Pi coding agent](https://github.com/earendil-wor
8
8
  - `/goal`: set an objective and Pi keeps working toward it across turns, compaction, reloads, and branch navigation. Each turn must record `continue` with evidence and one next action, `complete`, or the existing blocker decision; otherwise the goal pauses. New goals pause after 20 turns; `/goal resume` grants another 20.
9
9
  - `/codex-fast`: toggles the `priority` service tier on Codex requests or reports its status.
10
10
  - `/handoff`: starts a fresh linked session carrying visible continuation context.
11
- - Automatic context compaction when remaining tokens drop below 15% of the window (configurable).
11
+ - `/auto-compact`: reports, enables, disables, or tunes automatic context compaction.
12
12
  - A `question` tool with single-select and multi-select modes.
13
13
  - Lifecycle hooks (`tool_call`, `tool_result`, `agent_settled`) from `.pi/killeros-hooks.json`, plus `AGENTS.local.md` loading for trusted projects.
14
14
  - Optional completion sounds for settled requests.
@@ -32,7 +32,7 @@ Or from GitHub:
32
32
  pi install git:github.com/KyrosHendrix/pi-KillerOS
33
33
  ```
34
34
 
35
- Pin a release by appending its tag, for example `@v2.1.28`. Add `-l` to install only for the current project. Restart Pi after installing.
35
+ Pin a release by appending its tag, for example `@v2.1.29`. Add `-l` to install only for the current project. Restart Pi after installing.
36
36
 
37
37
  ## Commands
38
38
 
@@ -43,6 +43,8 @@ Pin a release by appending its tag, for example `@v2.1.28`. Add `-l` to install
43
43
  /goal resume Resume automatic continuation
44
44
  /goal clear Remove the current goal
45
45
  /codex-fast [status] Toggle or report Codex fast mode
46
+ /auto-compact [status|on|off|<percent>]
47
+ Inspect or change automatic compaction
46
48
  /notification Configure the completion sound
47
49
  /handoff [focus] Fresh session with continuation context
48
50
  /clear New session after confirmation
@@ -71,6 +73,8 @@ The packaged `killeros` theme activates on TUI start. Compaction triggers by def
71
73
  }
72
74
  ```
73
75
 
76
+ Use `/auto-compact status` to inspect the effective KillerOS preference, `/auto-compact on` or `/auto-compact off` to toggle it, and `/auto-compact <percent>` to set an integer threshold from 0 through 100. A threshold of 0 does not disable Pi's own token reserve.
77
+
74
78
  `handoffMaxTokens` caps the `/handoff` summary output at 8192 tokens by default; raise it when long sessions truncate the summary.
75
79
 
76
80
  State proof in the objective so the agent can verify it with its normal tools:
@@ -7,7 +7,8 @@ import {
7
7
  type ExtensionContext,
8
8
  } from "@earendil-works/pi-coding-agent";
9
9
  import { errorMessage } from "./errors.ts";
10
- import { createKillerosSettingsStore } from "./settings.ts";
10
+ import { GOAL_CONTINUATION_TYPE } from "./goal-runtime.ts";
11
+ import { createKillerosSettingsStore, type KillerosSettingsStore } from "./settings.ts";
11
12
 
12
13
  export const DEFAULT_AUTO_COMPACTION_PERCENT_REMAINING = 15;
13
14
  export const AUTO_COMPACTION_MESSAGE_TYPE = "killeros-auto-compaction";
@@ -32,6 +33,7 @@ export interface AutoCompactionGoalHandlers {
32
33
  export interface AutoCompactionDependencies {
33
34
  loadPreference?: (ctx: ExtensionContext) => AutoCompactionPreference;
34
35
  getCompactionSettings?: (ctx: ExtensionContext) => CompactionSettings;
36
+ settingsStore?: KillerosSettingsStore;
35
37
  goal?: AutoCompactionGoalHandlers;
36
38
  }
37
39
 
@@ -66,8 +68,8 @@ export function readAutoCompactionPreference(
66
68
  };
67
69
  }
68
70
 
69
- function defaultPreference(): AutoCompactionPreference {
70
- return readAutoCompactionPreference(createKillerosSettingsStore().load());
71
+ function defaultPreference(store: KillerosSettingsStore): AutoCompactionPreference {
72
+ return readAutoCompactionPreference(store.load());
71
73
  }
72
74
 
73
75
  function defaultCompactionSettings(ctx: ExtensionContext): CompactionSettings {
@@ -115,18 +117,60 @@ export function registerAutoCompaction(
115
117
  pi: ExtensionAPI,
116
118
  dependencies: AutoCompactionDependencies = {},
117
119
  ): void {
118
- const loadPreference = dependencies.loadPreference ?? (() => defaultPreference());
120
+ const settingsStore = dependencies.settingsStore ?? createKillerosSettingsStore();
121
+ const loadPreference = dependencies.loadPreference ?? (() => defaultPreference(settingsStore));
119
122
  const getCompactionSettings = dependencies.getCompactionSettings ?? defaultCompactionSettings;
120
123
  let request: AutoCompactionRequest | undefined;
121
124
  let armed = true;
125
+ let rearmOnNextLogicalRequest = false;
122
126
  let settingsErrorReported = false;
123
127
 
124
128
  const resetForLifecycle = (): void => {
125
129
  request = undefined;
126
130
  armed = true;
131
+ rearmOnNextLogicalRequest = false;
127
132
  settingsErrorReported = false;
128
133
  };
129
134
 
135
+ pi.registerCommand("auto-compact", {
136
+ description: "Inspect or change automatic compaction",
137
+ handler: async (args, ctx) => {
138
+ const argument = args.trim();
139
+ if (!argument || argument === "status") {
140
+ try {
141
+ const preference = readAutoCompactionPreference(settingsStore.load());
142
+ ctx.ui.notify(preference.enabled
143
+ ? `Automatic compaction: on at ${preference.percentRemaining}% remaining`
144
+ : `Automatic compaction: off (threshold ${preference.percentRemaining}%)`, "info");
145
+ } catch (error) {
146
+ ctx.ui.notify(`Automatic compaction setting could not be read: ${errorMessage(error)}`, "error");
147
+ }
148
+ return;
149
+ }
150
+
151
+ const percent = /^\d+$/u.test(argument) ? Number(argument) : undefined;
152
+ if (argument !== "on" && argument !== "off" && (percent === undefined || percent > 100)) {
153
+ ctx.ui.notify("Usage: /auto-compact [status|on|off|<percent 0-100>]", "error");
154
+ return;
155
+ }
156
+
157
+ try {
158
+ const preference = readAutoCompactionPreference(settingsStore.load());
159
+ const updated = percent === undefined
160
+ ? { ...preference, enabled: argument === "on" }
161
+ : { ...preference, percentRemaining: percent };
162
+ settingsStore.update({ autoCompaction: updated });
163
+ ctx.ui.notify(percent === undefined
164
+ ? updated.enabled
165
+ ? `Automatic compaction: on at ${updated.percentRemaining}% remaining`
166
+ : `Automatic compaction: off (threshold ${updated.percentRemaining}%)`
167
+ : `Automatic compaction threshold: ${percent}% remaining (${updated.enabled ? "on" : "off"})`, "info");
168
+ } catch (error) {
169
+ ctx.ui.notify(`Automatic compaction setting could not be saved: ${errorMessage(error)}`, "error");
170
+ }
171
+ },
172
+ });
173
+
130
174
  const notifyFailure = (ctx: ExtensionContext, error: unknown): void => {
131
175
  ctx.ui.notify(`Automatic compaction failed: ${errorMessage(error)}`, "error");
132
176
  };
@@ -250,6 +294,7 @@ export function registerAutoCompaction(
250
294
  ctx.compact({
251
295
  onComplete: () => {
252
296
  if (request?.phase !== "compacting" || request.token !== token) return;
297
+ rearmOnNextLogicalRequest = preference.percentRemaining === 100;
253
298
  if (goal && dependencies.goal) {
254
299
  request = undefined;
255
300
  try {
@@ -280,9 +325,15 @@ export function registerAutoCompaction(
280
325
  finalizeOrdinaryContinuation(ctx, request.token);
281
326
  });
282
327
  pi.on("message_start", (event) => {
283
- if (request?.phase === "continuation-dispatched"
284
- && event.message.role === "custom"
285
- && event.message.customType === AUTO_COMPACTION_MESSAGE_TYPE) request = undefined;
328
+ const isOrdinaryContinuation = event.message.role === "custom"
329
+ && event.message.customType === AUTO_COMPACTION_MESSAGE_TYPE;
330
+ if (request?.phase === "continuation-dispatched" && isOrdinaryContinuation) request = undefined;
331
+ const startsNewLogicalRequest = event.message.role === "user"
332
+ || (event.message.role === "custom" && event.message.customType === GOAL_CONTINUATION_TYPE);
333
+ if (rearmOnNextLogicalRequest && startsNewLogicalRequest) {
334
+ armed = true;
335
+ rearmOnNextLogicalRequest = false;
336
+ }
286
337
  });
287
338
 
288
339
  pi.on("session_start", resetForLifecycle);
@@ -1,6 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { createReadStream, watch } from "node:fs";
3
- import { PASSIVE_GIT_CONFIG_ARGS, passiveGitCommand, passiveGitEnv, passiveStatusSafetyArgs, samePassiveFilters } from "./passive-git-status.ts";
3
+ import { inspectWorktreeWithoutFilters, passiveGitCommand, passiveGitEnv, type PassiveWorktreeFile as DirtyFile } from "./passive-git-status.ts";
4
4
  import { lstat, open, readlink } from "node:fs/promises";
5
5
  import path from "node:path";
6
6
  import { createInflate } from "node:zlib";
@@ -140,44 +140,22 @@ type Repository = {
140
140
  blobCache: Map<string, Buffer>;
141
141
  blobCacheBytes: number;
142
142
  };
143
- const repositoryCache = new Map<string, Promise<Repository>>();
144
-
145
143
  async function repository(cwd: string): Promise<Repository> {
146
- const cached = repositoryCache.get(cwd);
147
- if (cached) return cached;
148
- const pending = (async () => {
149
- const result = decode(await runGit(cwd, ["rev-parse", "--path-format=absolute", "--show-toplevel", "--git-dir", "--git-common-dir"])).trimEnd().split(/\r?\n/u);
150
- const [root, gitDirectory, commonDirectory] = result;
151
- if (!root || !gitDirectory || !commonDirectory) throw new GitFailure("error");
152
- return {
153
- root,
154
- gitDirectory,
155
- commonDirectory,
156
- objectDirectory: path.join(commonDirectory, "objects"),
157
- blobCache: new Map(),
158
- blobCacheBytes: 0,
159
- };
160
- })();
161
- repositoryCache.set(cwd, pending);
162
- try {
163
- return await pending;
164
- } catch (error) {
165
- repositoryCache.delete(cwd);
166
- throw error;
167
- }
144
+ const result = decode(await runGit(cwd, [
145
+ "rev-parse", "--path-format=absolute", "--show-toplevel", "--git-dir", "--git-common-dir", "--git-path", "objects",
146
+ ])).trimEnd().split(/\r?\n/u);
147
+ const [root, gitDirectory, commonDirectory, objectDirectory] = result;
148
+ if (!root || !gitDirectory || !commonDirectory || !objectDirectory) throw new GitFailure("error");
149
+ return {
150
+ root,
151
+ gitDirectory,
152
+ commonDirectory,
153
+ objectDirectory,
154
+ blobCache: new Map(),
155
+ blobCacheBytes: 0,
156
+ };
168
157
  }
169
158
 
170
- type DirtyFile = {
171
- path: string;
172
- headMode?: string;
173
- headObjectId?: string;
174
- indexMode?: string;
175
- indexObjectId?: string;
176
- mode?: string;
177
- content?: Buffer;
178
- contentObjectId?: string;
179
- };
180
-
181
159
  async function readBoundedFile(filePath: string, limit: number): Promise<Buffer> {
182
160
  const handle = await open(filePath, "r");
183
161
  try {
@@ -198,47 +176,6 @@ async function readBoundedFile(filePath: string, limit: number): Promise<Buffer>
198
176
  }
199
177
  }
200
178
 
201
- async function readSnapshotFiles(repo: Repository, files: Map<string, DirtyFile>): Promise<void> {
202
- const pending = [...files.values()].filter((file) => file.mode && !file.contentObjectId);
203
- let totalBytes = 0;
204
- let nextIndex = 0;
205
- const readNext = async (): Promise<void> => {
206
- while (nextIndex < pending.length) {
207
- const file = pending[nextIndex];
208
- nextIndex += 1;
209
- if (!file?.mode) continue;
210
- const absolutePath = path.join(repo.root, ...file.path.split("/"));
211
- if (file.mode === "120000") {
212
- const content = Buffer.from(await readlink(absolutePath));
213
- totalBytes += content.length;
214
- if (totalBytes > SNAPSHOT_CONTENT_LIMIT) throw new GitFailure("too-large");
215
- file.content = content;
216
- continue;
217
- }
218
- const handle = await open(absolutePath, "r");
219
- try {
220
- const stats = await handle.stat();
221
- if (!stats.isFile()) throw new GitFailure("error");
222
- totalBytes += stats.size;
223
- if (totalBytes > SNAPSHOT_CONTENT_LIMIT) throw new GitFailure("too-large");
224
- const content = Buffer.alloc(stats.size + 1);
225
- let length = 0;
226
- while (length < content.length) {
227
- const { bytesRead } = await handle.read(content, length, content.length - length, length);
228
- if (bytesRead === 0) break;
229
- length += bytesRead;
230
- }
231
- if (length > stats.size) throw new GitFailure("error");
232
- totalBytes -= stats.size - length;
233
- file.content = content.subarray(0, length);
234
- } finally {
235
- await handle.close();
236
- }
237
- }
238
- };
239
- await Promise.all(Array.from({ length: Math.min(8, pending.length) }, readNext));
240
- }
241
-
242
179
  type Snapshot = { head: string; files: Map<string, DirtyFile> };
243
180
  type RepositoryMonitor = {
244
181
  changedPaths: Set<string>;
@@ -253,59 +190,23 @@ const repositoryMonitors = new Map<string, RepositoryMonitor>();
253
190
 
254
191
  function discardMonitor(monitor: RepositoryMonitor): void {
255
192
  for (const watcher of monitor.watchers) watcher.close();
256
- repositoryMonitors.delete(monitor.repo.root);
193
+ if (repositoryMonitors.get(monitor.repo.root) === monitor) repositoryMonitors.delete(monitor.repo.root);
257
194
  }
258
195
 
259
- async function snapshot(repo: Repository, paths?: readonly string[]): Promise<Snapshot> {
260
- const config = decode(await runGit(repo.root, PASSIVE_GIT_CONFIG_ARGS));
261
- const safetyArgs = passiveStatusSafetyArgs(config);
262
- if (!safetyArgs) throw new GitFailure("error");
263
- const output = decode(await runGit(repo.root, [
264
- ...safetyArgs,
265
- "status", "--porcelain=v2", "--branch", "--no-ahead-behind", "-z", "--no-renames", "--untracked-files=all", "--ignore-submodules=all",
266
- ...(paths ? ["--", ...paths] : []),
267
- ]));
268
- if (!samePassiveFilters(config, decode(await runGit(repo.root, PASSIVE_GIT_CONFIG_ARGS)))) throw new GitFailure("error");
269
- const records = output.split("\0").filter(Boolean);
270
- const headRecord = records.find((record) => record.startsWith("# branch.oid "));
271
- if (!headRecord) throw new Error("missing HEAD state");
272
- const files = new Map<string, DirtyFile>();
273
- for (const record of records) {
274
- if (record.startsWith("# ")) continue;
275
- if (record.startsWith("u ")) throw new Error("unmerged index");
276
- if (record.startsWith("? ")) {
277
- const filePath = record.slice(2);
278
- if (!filePath || filePath.endsWith("/")) continue;
279
- const stats = await lstat(path.join(repo.root, ...filePath.split("/")));
280
- files.set(filePath, {
281
- ...files.get(filePath),
282
- path: filePath,
283
- indexMode: undefined,
284
- indexObjectId: undefined,
285
- mode: stats.isSymbolicLink() ? "120000" : stats.mode & 0o111 ? "100755" : "100644",
286
- contentObjectId: undefined,
287
- });
288
- continue;
289
- }
290
- const match = /^1 (\S{2}) \S+ ([0-7]{6}) ([0-7]{6}) ([0-7]{6}) ([0-9a-f]+) ([0-9a-f]+) (.*)$/su.exec(record);
291
- if (!match) throw new Error("invalid status record");
292
- const [, status, headMode, indexMode, worktreeMode, headObjectId, indexObjectId, filePath] = match;
293
- if (!status || !headMode || !indexMode || !worktreeMode || !headObjectId || !indexObjectId || !filePath) throw new Error("incomplete status record");
294
- const worktreeChanged = status[1] !== ".";
295
- const selectedMode = worktreeChanged ? worktreeMode : indexMode;
296
- files.set(filePath, {
297
- ...files.get(filePath),
298
- path: filePath,
299
- headMode: headMode === "000000" ? undefined : headMode,
300
- headObjectId: /^0+$/u.test(headObjectId) ? undefined : headObjectId,
301
- indexMode: indexMode === "000000" ? undefined : indexMode,
302
- indexObjectId: /^0+$/u.test(indexObjectId) ? undefined : indexObjectId,
303
- mode: selectedMode === "000000" ? undefined : selectedMode,
304
- contentObjectId: worktreeChanged || /^0+$/u.test(indexObjectId) ? undefined : indexObjectId,
305
- });
196
+ function sameRepository(left: Repository, right: Repository): boolean {
197
+ return left.root === right.root
198
+ && left.gitDirectory === right.gitDirectory
199
+ && left.commonDirectory === right.commonDirectory
200
+ && left.objectDirectory === right.objectDirectory;
201
+ }
202
+
203
+ async function snapshot(repo: Repository): Promise<Snapshot> {
204
+ try {
205
+ return await inspectWorktreeWithoutFilters(repo.root, (args) => runGit(repo.root, args));
206
+ } catch (error) {
207
+ if (error instanceof Error && error.message === "worktree content limit exceeded") throw new GitFailure("too-large");
208
+ throw error;
306
209
  }
307
- await readSnapshotFiles(repo, files);
308
- return { head: headRecord.slice("# branch.oid ".length), files };
309
210
  }
310
211
 
311
212
  async function snapshotKnownPaths(repo: Repository, baseline: Snapshot, paths: readonly string[]): Promise<Snapshot | undefined> {
@@ -360,7 +261,8 @@ function observes(filePath: string, observedPaths: readonly string[]): boolean {
360
261
  async function refreshSnapshot(monitor: RepositoryMonitor, observedPaths: readonly string[], current = monitor.snapshot): Promise<Snapshot> {
361
262
  if (monitor.watchFailed || observedPaths.length > 200) return snapshot(monitor.repo);
362
263
  if (observedPaths.length === 0) return current;
363
- const scoped = await snapshotKnownPaths(monitor.repo, current, observedPaths) ?? await snapshot(monitor.repo, observedPaths);
264
+ const scoped = await snapshotKnownPaths(monitor.repo, current, observedPaths);
265
+ if (!scoped) return snapshot(monitor.repo);
364
266
  const files = new Map(current.files);
365
267
  for (const filePath of files.keys()) {
366
268
  if (observes(filePath, observedPaths)) files.delete(filePath);
@@ -714,11 +616,17 @@ export async function beginChangeReceipt(cwd: string, trusted = true): Promise<C
714
616
  };
715
617
  }
716
618
  try {
717
- const repo = await repository(cwd);
619
+ let repo = await repository(cwd);
718
620
  let monitor = repositoryMonitors.get(repo.root);
621
+ if (monitor && !sameRepository(monitor.repo, repo)) {
622
+ discardMonitor(monitor);
623
+ monitor = undefined;
624
+ }
719
625
  if (!monitor) {
720
626
  monitor = createMonitor(repo, await snapshot(repo));
721
627
  repositoryMonitors.set(repo.root, monitor);
628
+ } else {
629
+ repo = monitor.repo;
722
630
  }
723
631
  if (monitor.inUse) throw new Error("change collection already active");
724
632
  const idleChanges = [...monitor.changedPaths];
@@ -771,5 +679,4 @@ export async function beginChangeReceipt(cwd: string, trusted = true): Promise<C
771
679
  export function disposeChangeReceipts(): void {
772
680
  for (const monitor of repositoryMonitors.values()) discardMonitor(monitor);
773
681
  repositoryMonitors.clear();
774
- repositoryCache.clear();
775
682
  }
@@ -70,6 +70,7 @@ const BUILTIN_COMMANDS: ReadonlyArray<{ name: string; description: string }> = [
70
70
  ];
71
71
 
72
72
  const COMMAND_SYNTAX_HINTS: Readonly<Record<string, string>> = {
73
+ "auto-compact": "/auto-compact [status|on|off|<percent>]",
73
74
  goal: "/goal [objective|pause|resume|clear]",
74
75
  handoff: "/handoff [next-session focus]",
75
76
  model: "/model [provider/model]",
@@ -1,10 +1,11 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { watch } from "node:fs";
3
+ import path from "node:path";
3
4
  import { type ExtensionAPI, type ExtensionContext, type Theme, type ThemeColor } from "@earendil-works/pi-coding-agent";
4
5
  import { truncateToWidth, visibleWidth, type TUI } from "@earendil-works/pi-tui";
5
6
  import { isCodexFastEnabled, subscribeCodexFast } from "./codex-fast-state.ts";
6
7
  import { formatCwd, formatTime, modelDisplayName, padRight } from "./display.ts";
7
- import { PASSIVE_GIT_CONFIG_ARGS, passiveGitCommand, passiveGitEnv, passiveStatusSafetyArgs, samePassiveFilters } from "./passive-git-status.ts";
8
+ import { inspectWorktreeWithoutFilters, passiveGitCommand, passiveGitEnv, type PassiveGitRunner } from "./passive-git-status.ts";
8
9
  import { goalElapsedMilliseconds } from "./goal-state.ts";
9
10
  import type { GoalRuntime, GoalState } from "./runtime.ts";
10
11
  import { safeTerminalText } from "./safe-terminal-text.ts";
@@ -36,26 +37,16 @@ type GitStatusExecutor = (
36
37
  callback: (error: Error | null, stdout: string) => void,
37
38
  ) => unknown;
38
39
 
39
- /** Resolves changed-file counts with a bounded asynchronous Git status process in a trusted project. */
40
- export function resolveGitFileChanges(
40
+ /** Resolves changed-file counts without asking Git to inspect worktree content. */
41
+ export async function resolveGitFileChanges(
41
42
  cwd: string,
42
43
  execute: GitStatusExecutor = execFile,
43
44
  trusted = true,
44
45
  ): Promise<GitFileChanges | undefined> {
45
- if (!trusted) return Promise.resolve(undefined);
46
- return new Promise((resolve) => {
47
- let gitCommand: string;
48
- try {
49
- const found = passiveGitCommand(cwd);
50
- if (!found) {
51
- resolve(undefined);
52
- return;
53
- }
54
- gitCommand = found;
55
- } catch {
56
- resolve(undefined);
57
- return;
58
- }
46
+ if (!trusted) return undefined;
47
+ try {
48
+ const gitCommand = passiveGitCommand(cwd);
49
+ if (!gitCommand) return undefined;
59
50
  const options = {
60
51
  encoding: "utf8" as const,
61
52
  env: passiveGitEnv(),
@@ -63,64 +54,42 @@ export function resolveGitFileChanges(
63
54
  timeout: GIT_STATUS_TIMEOUT_MS,
64
55
  windowsHide: true as const,
65
56
  };
66
- const verifyFiltersUnchanged = (before: string, done: (unchanged: boolean) => void): void => {
67
- try {
68
- execute(gitCommand, ["-C", cwd, ...PASSIVE_GIT_CONFIG_ARGS], options, (error, after) => {
69
- done(!error && samePassiveFilters(before, after));
70
- });
71
- } catch {
72
- done(false);
73
- }
74
- };
75
- const runStatus = (config: string, args: string[]): void => {
76
- try {
77
- execute(gitCommand, args, options, (error, stdout) => {
78
- if (error) {
79
- resolve(undefined);
80
- return;
81
- }
82
- verifyFiltersUnchanged(config, (unchanged) => {
83
- if (!unchanged) {
84
- resolve(undefined);
85
- return;
86
- }
87
-
88
- const changes: GitFileChanges = { modified: 0, added: 0, deleted: 0 };
89
- const entries = stdout.split("\0");
90
- for (let index = 0; index < entries.length; index += 1) {
91
- const entry = entries[index];
92
- if (!entry) continue;
93
- const status = entry.slice(0, 2);
94
- if (status.includes("D")) changes.deleted += 1;
95
- else if (status === "??" || status.includes("A")) changes.added += 1;
96
- else changes.modified += 1;
97
- if (status.includes("R") || status.includes("C")) index += 1;
98
- }
99
- resolve(changes);
100
- });
101
- });
102
- } catch {
103
- resolve(undefined);
104
- }
105
- };
106
-
107
- try {
108
- execute(gitCommand, ["-C", cwd, ...PASSIVE_GIT_CONFIG_ARGS], options, (error, config) => {
109
- if (error) {
110
- resolve(undefined);
111
- return;
112
- }
113
- const safetyArgs = passiveStatusSafetyArgs(config);
114
- if (!safetyArgs) {
115
- resolve(undefined);
116
- return;
117
- }
118
- runStatus(config, ["-C", cwd, ...safetyArgs, "status", "--porcelain=v1", "-z", "--untracked-files=all"]);
57
+ const runAt = (directory: string): PassiveGitRunner => (args) => new Promise((resolve, reject) => {
58
+ execute(gitCommand, ["-C", directory, ...args], options, (error, stdout) => {
59
+ if (error) reject(error);
60
+ else resolve(Buffer.from(stdout));
119
61
  });
120
- } catch {
121
- resolve(undefined);
62
+ });
63
+ const initialRunner = runAt(cwd);
64
+ const root = new TextDecoder("utf-8", { fatal: true }).decode(
65
+ await initialRunner(["rev-parse", "--path-format=absolute", "--show-toplevel"]),
66
+ ).trim();
67
+ if (!path.isAbsolute(root)) return undefined;
68
+ const snapshot = await inspectWorktreeWithoutFilters(root, runAt(root));
69
+ const changes: GitFileChanges = { modified: 0, added: 0, deleted: 0 };
70
+ const deletedObjectIds: string[] = [];
71
+ const addedObjectIds: string[] = [];
72
+ for (const file of snapshot.files.values()) {
73
+ if (!file.mode) {
74
+ changes.deleted += 1;
75
+ if (!file.indexMode && file.headObjectId) deletedObjectIds.push(file.headObjectId);
76
+ } else if (!file.headMode) {
77
+ changes.added += 1;
78
+ if (file.indexObjectId) addedObjectIds.push(file.indexObjectId);
79
+ } else changes.modified += 1;
122
80
  }
123
- });
81
+ for (const objectId of deletedObjectIds) {
82
+ const match = addedObjectIds.indexOf(objectId);
83
+ if (match < 0) continue;
84
+ addedObjectIds.splice(match, 1);
85
+ changes.deleted -= 1;
86
+ changes.added -= 1;
87
+ changes.modified += 1;
88
+ }
89
+ return changes;
90
+ } catch {
91
+ return undefined;
92
+ }
124
93
  }
125
94
 
126
95
  async function resolveUncommittedFileCount(cwd: string): Promise<number | undefined> {
@@ -15,7 +15,7 @@ import type { GoalRuntime, GoalState, GoalStatus } from "./runtime.ts";
15
15
  import { safeTerminalText } from "./safe-terminal-text.ts";
16
16
 
17
17
  export const GOAL_ENTRY_TYPE = "killeros-goal";
18
- const GOAL_CONTINUATION_TYPE = "killeros-goal-continuation";
18
+ export const GOAL_CONTINUATION_TYPE = "killeros-goal-continuation";
19
19
  export const GOAL_UPDATE_TOOL = "killeros_goal_update";
20
20
 
21
21
  export type GoalEntryEvent = "set" | "replace" | "limit" | "turn" | "continue" | "pause" | "resume" | "blocked" | "complete" | "error" | "clear" | "checkpoint" | "blocker-audit";
@@ -1,21 +1,40 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { accessSync, constants, existsSync, realpathSync, statSync } from "node:fs";
3
+ import { lstat, open, readlink } from "node:fs/promises";
2
4
  import path from "node:path";
3
5
 
4
- // Passive Git inspection disables fsmonitor, known clean/process filters,
5
- // promisor fetches, and repository-supplied Git executables. Config discovery
6
- // and status run in separate Git processes, so a filter configured between
7
- // them would be absent from the safety overrides. Automatic Git children run
8
- // without PATH resolution, and each scan rejects results when the effective
9
- // filter set changed. Absolute filter commands remain possible, so product
10
- // callers run these scans only after project trust is granted.
11
- //
12
- // Executable discovery itself is passive: it never starts a command shell,
13
- // locator process, or other helper executable, and never executes a bare
14
- // program name. It scans absolute search-path entries for an absolute Git
15
- // path outside the inspected repository and fails closed when none exists.
16
- export const PASSIVE_GIT_CONFIG_ARGS = ["config", "--includes", "--null", "--name-only", "--list"] as const;
17
-
18
- const SAFE_FILTER_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/u;
6
+ const WORKTREE_CONTENT_LIMIT = 128 * 1024 * 1024;
7
+ const PASSIVE_METADATA_ARGS = ["-c", "core.fsmonitor=false"] as const;
8
+
9
+ export type PassiveGitRunner = (args: readonly string[]) => Promise<Buffer>;
10
+
11
+ export type PassiveWorktreeFile = {
12
+ path: string;
13
+ headMode?: string;
14
+ headObjectId?: string;
15
+ indexMode?: string;
16
+ indexObjectId?: string;
17
+ mode?: string;
18
+ content?: Buffer;
19
+ contentObjectId?: string;
20
+ };
21
+
22
+ export type PassiveWorktreeSnapshot = {
23
+ head: string;
24
+ files: Map<string, PassiveWorktreeFile>;
25
+ };
26
+
27
+ type IndexEntry = {
28
+ mode: string;
29
+ objectId: string;
30
+ eolAttributes: string;
31
+ ctimeNanoseconds: bigint;
32
+ mtimeNanoseconds: bigint;
33
+ size: bigint;
34
+ skipWorktree: boolean;
35
+ };
36
+
37
+ type TreeEntry = { mode: string; objectId: string };
19
38
 
20
39
  function searchPathEntries(env: NodeJS.ProcessEnv): string[] {
21
40
  const values: string[] = [];
@@ -101,13 +120,7 @@ function insideInspected(candidate: string, root: string): boolean {
101
120
  return normalizedCandidate.startsWith(`${trimmed}${path.sep}`);
102
121
  }
103
122
 
104
- // Absolute Git binary outside the inspected repository, or undefined when
105
- // no safe candidate exists. Never starts a helper process and never
106
- // returns a bare command name, so opening a repository cannot execute a
107
- // repository-local locator or Git executable. Empty and relative
108
- // search-path entries are ignored because they can resolve against the
109
- // current directory. A candidate that resolves through a link returns its
110
- // final path only when that path is also outside the repository.
123
+ /** Returns an absolute Git binary outside the inspected repository without starting a locator process. */
111
124
  export function passiveGitCommand(cwd: string, env: NodeJS.ProcessEnv = process.env): string | undefined {
112
125
  const root = inspectedRoot(cwd);
113
126
  if (!root) return undefined;
@@ -118,8 +131,7 @@ export function passiveGitCommand(cwd: string, env: NodeJS.ProcessEnv = process.
118
131
  for (const raw of entries) {
119
132
  if (raw === "" || raw.trim() === "") continue;
120
133
  const directory = unquoted(raw);
121
- if (directory === "" || directory.trim() === "") continue;
122
- if (!path.isAbsolute(directory)) continue;
134
+ if (directory === "" || directory.trim() === "" || !path.isAbsolute(directory)) continue;
123
135
  for (const base of baseNames) {
124
136
  const candidate = path.join(directory, base);
125
137
  try {
@@ -140,60 +152,15 @@ export function passiveGitCommand(cwd: string, env: NodeJS.ProcessEnv = process.
140
152
  } catch {
141
153
  continue;
142
154
  }
143
- if (!path.isAbsolute(resolved)) continue;
144
- if (insideInspected(resolved, root)) continue;
145
- return resolved;
155
+ if (path.isAbsolute(resolved) && !insideInspected(resolved, root)) return resolved;
146
156
  }
147
157
  }
148
158
  return undefined;
149
159
  }
150
160
 
151
- // Lists effective clean/process filter drivers in discovery order, or
152
- // undefined when discovery output is incomplete or names a driver the
153
- // safety overrides cannot represent.
154
- export function passiveFilterNames(config: string): string[] | undefined {
155
- const records = config.split("\0");
156
- if (records.at(-1) !== "") return undefined;
157
- const names = new Set<string>();
158
- for (const key of records) {
159
- if (!key) continue;
160
- const name = /^filter\.(.*)\.(?:clean|process)$/us.exec(key)?.[1];
161
- if (name === undefined) continue;
162
- if (!SAFE_FILTER_NAME.test(name)) return undefined;
163
- names.add(name);
164
- }
165
- return [...names];
166
- }
167
-
168
- // Builds safe overrides from null-delimited `git config --name-only --list`
169
- // output. Returns undefined when output is incomplete or names an unsafe
170
- // filter, so the caller skips the status call.
171
- export function passiveStatusSafetyArgs(config: string): string[] | undefined {
172
- const names = passiveFilterNames(config);
173
- if (!names) return undefined;
174
- return ["-c", "core.fsmonitor=false", ...names.flatMap((name) => ["-c", `filter.${name}.clean=`, "-c", `filter.${name}.process=`, "-c", `filter.${name}.required=false`])];
175
- }
176
-
177
- export function samePassiveFilters(before: string, after: string): boolean {
178
- const earlier = passiveFilterNames(before);
179
- const later = passiveFilterNames(after);
180
- if (!earlier || !later) return false;
181
- if (earlier.length !== later.length) return false;
182
- const ordered = [...later].sort();
183
- return [...earlier].sort().every((name, index) => name === ordered[index]);
184
- }
185
-
186
- // Environment for automatic Git children: no optional locks, no lazy fetch
187
- // from a promisor remote, and no PATH so a filter command that becomes
188
- // effective after discovery cannot resolve a bare command name. Absolute
189
- // filter paths are still possible; trusted-project callers detect mid-scan
190
- // config changes with samePassiveFilters and skip those results.
161
+ /** Environment for automatic Git children, with locks, lazy fetches, and PATH command resolution disabled. */
191
162
  export function passiveGitEnv(base: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
192
- const env: NodeJS.ProcessEnv = {
193
- ...base,
194
- GIT_OPTIONAL_LOCKS: "0",
195
- GIT_NO_LAZY_FETCH: "1",
196
- };
163
+ const env: NodeJS.ProcessEnv = { ...base, GIT_OPTIONAL_LOCKS: "0", GIT_NO_LAZY_FETCH: "1" };
197
164
  let hasPath = false;
198
165
  for (const key of Object.keys(env)) {
199
166
  if (key.toLowerCase() === "path") {
@@ -204,3 +171,176 @@ export function passiveGitEnv(base: NodeJS.ProcessEnv = process.env): NodeJS.Pro
204
171
  if (!hasPath) env.PATH = "";
205
172
  return env;
206
173
  }
174
+
175
+ function decode(buffer: Buffer): string {
176
+ return new TextDecoder("utf-8", { fatal: true }).decode(buffer);
177
+ }
178
+
179
+ function parseIndex(output: Buffer): Map<string, IndexEntry> {
180
+ const text = decode(output);
181
+ const pattern = /([0-7]{6}) ([0-9a-f]{40}(?:[0-9a-f]{24})?) ([0-3])\t([^\t]*)\t([^\0]*)\0 ctime: (\d+):(\d+)\n mtime: (\d+):(\d+)\n dev: \d+\tino: \d+\n uid: \d+\tgid: \d+\n size: (\d+)\tflags: ([0-9a-f]+)\n/gu;
182
+ const entries = new Map<string, IndexEntry>();
183
+ let end = 0;
184
+ for (const match of text.matchAll(pattern)) {
185
+ if (match.index !== end) throw new Error("invalid index metadata");
186
+ end = match.index + match[0].length;
187
+ const [, mode, objectId, stage, eolAttributes, filePath, ctimeSeconds, ctimeNanoseconds, mtimeSeconds, mtimeNanoseconds, size, flags] = match;
188
+ if (!mode || !objectId || !stage || eolAttributes === undefined || filePath === undefined || !ctimeSeconds || !ctimeNanoseconds || !mtimeSeconds || !mtimeNanoseconds || !size || !flags || stage !== "0") {
189
+ throw new Error("unsupported index entry");
190
+ }
191
+ entries.set(filePath, {
192
+ mode,
193
+ objectId,
194
+ eolAttributes: eolAttributes.trim(),
195
+ ctimeNanoseconds: BigInt(ctimeSeconds) * 1_000_000_000n + BigInt(ctimeNanoseconds),
196
+ mtimeNanoseconds: BigInt(mtimeSeconds) * 1_000_000_000n + BigInt(mtimeNanoseconds),
197
+ size: BigInt(size),
198
+ skipWorktree: (Number.parseInt(flags, 16) & 0x40000000) !== 0,
199
+ });
200
+ }
201
+ if (end !== text.length) throw new Error("incomplete index metadata");
202
+ return entries;
203
+ }
204
+
205
+ function parseTree(output: Buffer): Map<string, TreeEntry> {
206
+ const entries = new Map<string, TreeEntry>();
207
+ for (const record of decode(output).split("\0")) {
208
+ if (!record) continue;
209
+ const match = /^([0-7]{6}) (?:blob|commit) ([0-9a-f]{40}(?:[0-9a-f]{24})?)\t([^\0]+)$/u.exec(record);
210
+ if (!match?.[1] || !match[2] || !match[3]) throw new Error("invalid tree entry");
211
+ entries.set(match[3], { mode: match[1], objectId: match[2] });
212
+ }
213
+ return entries;
214
+ }
215
+
216
+ function parsePaths(output: Buffer): string[] {
217
+ const records = decode(output).split("\0");
218
+ if (records.at(-1) !== "") throw new Error("incomplete path list");
219
+ return records.slice(0, -1);
220
+ }
221
+
222
+ async function readFileState(root: string, entry: IndexEntry | undefined, filePath: string, remaining: number): Promise<{ mode: string; content?: Buffer; statsMatch: boolean }> {
223
+ const absolutePath = path.join(root, ...filePath.split("/"));
224
+ const stats = await lstat(absolutePath, { bigint: true });
225
+ const mode = stats.isSymbolicLink() ? "120000" : stats.isFile() ? stats.mode & 0o111n ? "100755" : "100644" : "";
226
+ if (!mode) throw new Error("unsupported worktree entry");
227
+ const statsMatch = entry !== undefined
228
+ && entry.mode === mode
229
+ && entry.size === stats.size
230
+ && entry.ctimeNanoseconds === stats.ctimeNs
231
+ && entry.mtimeNanoseconds === stats.mtimeNs;
232
+ if (statsMatch) return { mode, statsMatch };
233
+ if (!stats.isSymbolicLink() && stats.size > BigInt(remaining)) throw new Error("worktree content limit exceeded");
234
+ const content = stats.isSymbolicLink() ? Buffer.from(await readlink(absolutePath)) : await readBounded(absolutePath, remaining);
235
+ return { mode, content, statsMatch };
236
+ }
237
+
238
+ async function readBounded(filePath: string, limit: number): Promise<Buffer> {
239
+ const handle = await open(filePath, "r");
240
+ try {
241
+ const stats = await handle.stat();
242
+ if (!stats.isFile() || stats.size > limit) throw new Error("worktree content limit exceeded");
243
+ const content = Buffer.alloc(stats.size + 1);
244
+ let length = 0;
245
+ while (length < content.length) {
246
+ const { bytesRead } = await handle.read(content, length, content.length - length, length);
247
+ if (bytesRead === 0) break;
248
+ length += bytesRead;
249
+ }
250
+ if (length > stats.size) throw new Error("worktree changed while reading");
251
+ return content.subarray(0, length);
252
+ } finally {
253
+ await handle.close();
254
+ }
255
+ }
256
+
257
+ function blobObjectId(content: Buffer, length: number): string {
258
+ const algorithm = length === 64 ? "sha256" : "sha1";
259
+ return createHash(algorithm).update(`blob ${content.length}\0`).update(content).digest("hex");
260
+ }
261
+
262
+ function matchesIndex(content: Buffer, entry: IndexEntry, autoCrlf: string): boolean {
263
+ if (blobObjectId(content, entry.objectId.length) === entry.objectId) return true;
264
+ if (entry.mode === "120000" || !content.includes(Buffer.from("\r\n"))) return false;
265
+ const attributes = entry.eolAttributes;
266
+ if (attributes === "-text") return false;
267
+ const automatic = attributes.startsWith("text=auto") || !attributes;
268
+ if (!attributes && !["true", "input"].includes(autoCrlf)) return false;
269
+ if (automatic) {
270
+ // Match Git's convert_is_binary heuristic, including its terminal EOF exception.
271
+ let printable = 0;
272
+ let nonprintable = 0;
273
+ for (let index = 0; index < content.length; index += 1) {
274
+ const byte = content[index];
275
+ if (byte === 0 || byte === 13 && content[index + 1] !== 10) return false;
276
+ if (byte === 13 || byte === 10 || byte === 26 && index === content.length - 1) continue;
277
+ if (byte === 127 || byte < 32 && ![8, 9, 12, 27].includes(byte)) nonprintable += 1;
278
+ else printable += 1;
279
+ }
280
+ if (Math.floor(printable / 128) < nonprintable) return false;
281
+ }
282
+ const normalized = Buffer.from(content.toString("latin1").replaceAll("\r\n", "\n"), "latin1");
283
+ return blobObjectId(normalized, entry.objectId.length) === entry.objectId;
284
+ }
285
+
286
+ /**
287
+ * Reads index and tree metadata with Git, then inspects worktree files with Node.
288
+ * No Git command in this path asks Git to convert worktree content, so clean and process filters cannot start.
289
+ */
290
+ export async function inspectWorktreeWithoutFilters(root: string, runGit: PassiveGitRunner): Promise<PassiveWorktreeSnapshot> {
291
+ const [indexOutput, untrackedOutput, resolvedHead, autoCrlfOutput] = await Promise.all([
292
+ runGit([...PASSIVE_METADATA_ARGS, "ls-files", "--cached", "--debug", "-z", "--full-name",
293
+ "--format=%(objectmode) %(objectname) %(stage)%x09%(eolattr)%x09%(path)"]),
294
+ runGit([...PASSIVE_METADATA_ARGS, "ls-files", "--others", "--exclude-standard", "-z", "--full-name"]),
295
+ runGit(["rev-parse", "--verify", "HEAD"]).then(decode, () => ""),
296
+ runGit(["config", "--type=bool-or-str", "--default", "false", "--get", "core.autocrlf"]),
297
+ ]);
298
+ const autoCrlf = decode(autoCrlfOutput).trim().toLowerCase();
299
+ const head = resolvedHead.trim();
300
+ if (head && !/^[0-9a-f]{40}(?:[0-9a-f]{24})?$/u.test(head)) throw new Error("invalid HEAD");
301
+ const index = parseIndex(indexOutput);
302
+ const tree = head ? parseTree(await runGit(["ls-tree", "-r", "-z", "--full-tree", head])) : new Map<string, TreeEntry>();
303
+ const files = new Map<string, PassiveWorktreeFile>();
304
+ let contentBytes = 0;
305
+
306
+ for (const filePath of new Set([...tree.keys(), ...index.keys()])) {
307
+ const headEntry = tree.get(filePath);
308
+ const indexEntry = index.get(filePath);
309
+ if (headEntry?.mode === "160000" || indexEntry?.mode === "160000") continue;
310
+ const staged = headEntry?.mode !== indexEntry?.mode || headEntry?.objectId !== indexEntry?.objectId;
311
+ let mode: string | undefined;
312
+ let content: Buffer | undefined;
313
+ let worktreeChanged = false;
314
+ if (indexEntry && !indexEntry.skipWorktree) {
315
+ try {
316
+ const state = await readFileState(root, indexEntry, filePath, WORKTREE_CONTENT_LIMIT - contentBytes);
317
+ mode = process.platform === "win32" && indexEntry.mode !== "120000" ? indexEntry.mode : state.mode;
318
+ content = state.content;
319
+ if (content) contentBytes += content.length;
320
+ worktreeChanged = !state.statsMatch && (mode !== indexEntry.mode || !content || !matchesIndex(content, indexEntry, autoCrlf));
321
+ } catch (error) {
322
+ if (error !== null && typeof error === "object" && "code" in error && error.code === "ENOENT") worktreeChanged = true;
323
+ else throw error;
324
+ }
325
+ }
326
+ if (!staged && !worktreeChanged) continue;
327
+ files.set(filePath, {
328
+ path: filePath,
329
+ headMode: headEntry?.mode,
330
+ headObjectId: headEntry?.objectId,
331
+ indexMode: indexEntry?.mode,
332
+ indexObjectId: indexEntry?.objectId,
333
+ mode: worktreeChanged ? mode : indexEntry?.mode,
334
+ ...(worktreeChanged && content ? { content } : indexEntry ? { contentObjectId: indexEntry.objectId } : {}),
335
+ });
336
+ }
337
+
338
+ for (const filePath of parsePaths(untrackedOutput)) {
339
+ const state = await readFileState(root, undefined, filePath, WORKTREE_CONTENT_LIMIT - contentBytes);
340
+ if (!state.content) throw new Error("missing untracked content");
341
+ contentBytes += state.content.length;
342
+ const previous = files.get(filePath);
343
+ files.set(filePath, { ...previous, path: filePath, mode: state.mode, content: state.content, contentObjectId: undefined });
344
+ }
345
+ return { head: head || "(initial)", files };
346
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "killeros",
3
- "version": "2.1.28",
3
+ "version": "2.1.29",
4
4
  "description": "TUI, goals, and workflow automation for the Pi coding agent",
5
5
  "type": "module",
6
6
  "keywords": [