killeros 2.1.25 → 2.1.26

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,14 @@ All notable changes to KillerOS are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [2.1.26] - 2026-09-08
8
+
9
+ ### Fixed
10
+
11
+ - Restored Pi lifecycle loading, blocked footer Git status from invoking configured filesystem monitors, and kept replaced `/init` guidance at a disclosed recovery path so late writes remain recoverable.
12
+ - Preserved Unicode blocker evidence when restoring goals, rejected cancelled goal updates before completion is saved, and read untracked symlink destinations instead of their targets in change receipts.
13
+ - Kept `/init` isolated from proactive compaction, refreshed Git filter safeguards for each receipt scan, and preserved compaction recovery after cancelled session replacements.
14
+
7
15
  ## [2.1.25] - 2026-09-06
8
16
 
9
17
  ### Fixed
package/Killeros.ts CHANGED
@@ -53,7 +53,7 @@ export default function Killeros(pi: ExtensionAPI, options: KillerosOptions = {}
53
53
  registerLifecycleHooks(pi);
54
54
  registerWorkedFor(pi);
55
55
  const goalCompaction = registerGoalSettlement(pi, goalRuntime, initRuntime);
56
- registerAutoCompaction(pi, { goal: goalCompaction });
56
+ registerAutoCompaction(pi, { goal: goalCompaction, isInitActive: () => initRuntime.active });
57
57
  registerInitSettlement(pi, initRuntime);
58
58
  registerRequestActivity(pi);
59
59
  registerCompletionNotifications(pi, options.completionNotifications);
package/README.md CHANGED
@@ -33,7 +33,7 @@ Or from GitHub:
33
33
  pi install git:github.com/KyrosHendrix/pi-KillerOS
34
34
  ```
35
35
 
36
- Pin a release by appending its tag, for example `@v2.1.25`. Add `-l` to install only for the current project. Restart Pi after installing.
36
+ Pin a release by appending its tag, for example `@v2.1.26`. Add `-l` to install only for the current project. Restart Pi after installing.
37
37
 
38
38
  ## Commands
39
39
 
@@ -33,6 +33,7 @@ export interface AutoCompactionDependencies {
33
33
  loadPreference?: (ctx: ExtensionContext) => AutoCompactionPreference;
34
34
  getCompactionSettings?: (ctx: ExtensionContext) => CompactionSettings;
35
35
  goal?: AutoCompactionGoalHandlers;
36
+ isInitActive?: () => boolean;
36
37
  }
37
38
 
38
39
  type AutoCompactionRequest = {
@@ -192,7 +193,7 @@ export function registerAutoCompaction(
192
193
  };
193
194
 
194
195
  pi.on("turn_end", (_event, ctx) => {
195
- if (!supportedMode(ctx) || request) return;
196
+ if (!supportedMode(ctx) || dependencies.isInitActive?.() === true || request) return;
196
197
 
197
198
  let preference: AutoCompactionPreference;
198
199
  let compactionSettings: CompactionSettings;
@@ -288,6 +289,4 @@ export function registerAutoCompaction(
288
289
  pi.on("session_start", resetForLifecycle);
289
290
  pi.on("session_shutdown", resetForLifecycle);
290
291
  pi.on("session_tree", resetForLifecycle);
291
- pi.on("session_before_switch", resetForLifecycle);
292
- pi.on("session_before_fork", resetForLifecycle);
293
292
  }
@@ -119,47 +119,23 @@ function missingFile(error: unknown): boolean {
119
119
  return error !== null && typeof error === "object" && "code" in error && error.code === "ENOENT";
120
120
  }
121
121
 
122
- type FilterConfiguration = { names: readonly string[]; sources: ReadonlyMap<string, Buffer> };
123
122
  type Repository = {
124
123
  root: string;
125
124
  gitDirectory: string;
126
125
  commonDirectory: string;
127
126
  objectDirectory: string;
128
- filterConfiguration: FilterConfiguration;
129
127
  blobCache: Map<string, Buffer>;
130
128
  blobCacheBytes: number;
131
129
  };
132
130
  const repositoryCache = new Map<string, Promise<Repository>>();
133
131
 
134
- async function loadFilterConfiguration(root: string, gitDirectory: string): Promise<FilterConfiguration> {
135
- const records = decode(await runGit(root, ["config", "--null", "--show-origin", "--name-only", "--list"])).split("\0");
132
+ async function loadFilterNames(root: string): Promise<readonly string[]> {
133
+ const records = decode(await runGit(root, ["config", "--null", "--name-only", "--list"])).split("\0");
136
134
  const names = new Set<string>();
137
- const sourcePaths = new Set<string>([path.join(gitDirectory, "HEAD")]);
138
- for (let index = 0; index + 1 < records.length; index += 2) {
139
- const origin = records[index];
140
- const key = records[index + 1];
141
- if (origin?.startsWith("file:")) sourcePaths.add(path.resolve(root, origin.slice("file:".length)));
135
+ for (const key of records) {
142
136
  if (key && /^filter\..*\.(clean|process)$/u.test(key)) names.add(key.slice("filter.".length, key.lastIndexOf(".")));
143
137
  }
144
- const sources = new Map<string, Buffer>();
145
- for (const sourcePath of sourcePaths) sources.set(sourcePath, await readBoundedFile(sourcePath, GIT_OUTPUT_LIMIT));
146
- return { names: [...names], sources };
147
- }
148
-
149
- async function currentFilterNames(repo: Repository): Promise<readonly string[]> {
150
- for (const [sourcePath, previous] of repo.filterConfiguration.sources) {
151
- try {
152
- if (!(await readBoundedFile(sourcePath, GIT_OUTPUT_LIMIT)).equals(previous)) {
153
- repo.filterConfiguration = await loadFilterConfiguration(repo.root, repo.gitDirectory);
154
- break;
155
- }
156
- } catch (error) {
157
- if (!missingFile(error)) throw error;
158
- repo.filterConfiguration = await loadFilterConfiguration(repo.root, repo.gitDirectory);
159
- break;
160
- }
161
- }
162
- return repo.filterConfiguration.names;
138
+ return [...names];
163
139
  }
164
140
 
165
141
  async function repository(cwd: string): Promise<Repository> {
@@ -174,7 +150,6 @@ async function repository(cwd: string): Promise<Repository> {
174
150
  gitDirectory,
175
151
  commonDirectory,
176
152
  objectDirectory: path.join(commonDirectory, "objects"),
177
- filterConfiguration: await loadFilterConfiguration(root, gitDirectory),
178
153
  blobCache: new Map(),
179
154
  blobCacheBytes: 0,
180
155
  };
@@ -278,7 +253,7 @@ function discardMonitor(monitor: RepositoryMonitor): void {
278
253
  }
279
254
 
280
255
  async function snapshot(repo: Repository, paths?: readonly string[]): Promise<Snapshot> {
281
- const filterNames = await currentFilterNames(repo);
256
+ const filterNames = await loadFilterNames(repo.root);
282
257
  const output = decode(await runGit(repo.root, [
283
258
  "-c", "core.fsmonitor=false",
284
259
  ...filterNames.flatMap((name) => ["-c", `filter.${name}.clean=`, "-c", `filter.${name}.process=`, "-c", `filter.${name}.required=false`]),
@@ -295,12 +270,13 @@ async function snapshot(repo: Repository, paths?: readonly string[]): Promise<Sn
295
270
  if (record.startsWith("? ")) {
296
271
  const filePath = record.slice(2);
297
272
  if (!filePath || filePath.endsWith("/")) continue;
273
+ const stats = await lstat(path.join(repo.root, ...filePath.split("/")));
298
274
  files.set(filePath, {
299
275
  ...files.get(filePath),
300
276
  path: filePath,
301
277
  indexMode: undefined,
302
278
  indexObjectId: undefined,
303
- mode: "100644",
279
+ mode: stats.isSymbolicLink() ? "120000" : stats.mode & 0o111 ? "100755" : "100644",
304
280
  contentObjectId: undefined,
305
281
  });
306
282
  continue;
@@ -43,7 +43,7 @@ export function resolveGitFileChanges(
43
43
  return new Promise((resolve) => {
44
44
  execute(
45
45
  "git",
46
- ["-C", cwd, "status", "--porcelain=v1", "-z", "--untracked-files=all"],
46
+ ["-C", cwd, "-c", "core.fsmonitor=false", "status", "--porcelain=v1", "-z", "--untracked-files=all"],
47
47
  {
48
48
  encoding: "utf8",
49
49
  env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" },
@@ -90,6 +90,7 @@ export function registerGoalInterface(
90
90
  parameters: GoalUpdateParams,
91
91
  executionMode: "sequential",
92
92
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
93
+ signal?.throwIfAborted();
93
94
  if (!isGoalModeSupported(ctx)) throw new Error("KillerOS goals require TUI or RPC mode");
94
95
  if (!isSavedSession(ctx)) throw new Error("KillerOS goals require a saved session");
95
96
  const state = runtime.state;
@@ -98,6 +99,7 @@ export function registerGoalInterface(
98
99
  if (!evidence) throw new Error("Goal evidence must not be empty");
99
100
  if (params.status === "complete") {
100
101
  if (state.verification) await verifyGoalDeliverable(state.verification);
102
+ signal?.throwIfAborted();
101
103
  if (runtime.state !== state) throw new Error("Goal changed while completion was being verified");
102
104
  const verification = state.verification ? "file" : "model-reported";
103
105
  transitionGoal(pi, runtime, "complete", "complete", evidence, { resetBlockedAudit: true });
@@ -224,10 +224,6 @@ export function registerGoalSettlement(
224
224
  recoverGoalAfterManualCompaction(pi, runtime, initState, ctx);
225
225
  });
226
226
 
227
- const resetAutomaticRecovery = (): void => { runtime.automaticCompaction = undefined; };
228
- pi.on("session_before_switch", resetAutomaticRecovery);
229
- pi.on("session_before_fork", resetAutomaticRecovery);
230
-
231
227
  return {
232
228
  isActive: (ctx: ExtensionContext): boolean => isGoalModeSupported(ctx)
233
229
  && isSavedSession(ctx)
@@ -10,6 +10,7 @@ export const GOAL_MAX_TURNS = 10_000;
10
10
  export const GOAL_VERSION = 1;
11
11
  const FILE_HASH_CHUNK_SIZE = 64 * 1024;
12
12
  export const FILE_HASH_LIMIT = 64 * 1024 * 1024;
13
+ const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
13
14
  type OpenGoalFile = (filePath: string) => Promise<FileHandle>;
14
15
  const openGoalFile: OpenGoalFile = (filePath) => open(filePath, "r");
15
16
 
@@ -84,6 +85,14 @@ function isMaxTurns(value: unknown): value is number {
84
85
  return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= GOAL_MAX_TURNS;
85
86
  }
86
87
 
88
+ function exceedsBlockerEvidenceLimit(value: string): boolean {
89
+ let length = 0;
90
+ for (const _ of graphemeSegmenter.segment(value)) {
91
+ if (++length > 2_000) return true;
92
+ }
93
+ return false;
94
+ }
95
+
87
96
  function isGoalBlockerAudit(value: unknown, turns: number, status: GoalStatus): value is GoalBlockerAudit {
88
97
  if (!isUnknownRecord(value)
89
98
  || typeof value.key !== "string"
@@ -91,7 +100,7 @@ function isGoalBlockerAudit(value: unknown, turns: number, status: GoalStatus):
91
100
  || typeof value.streak !== "number" || !Number.isInteger(value.streak) || value.streak < 1 || value.streak > 3
92
101
  || typeof value.lastTurn !== "number" || !Number.isInteger(value.lastTurn) || value.lastTurn < 1 || value.lastTurn > turns
93
102
  || value.evidence !== undefined && (typeof value.evidence !== "string"
94
- || value.evidence !== value.evidence.trim() || !value.evidence || value.evidence.length > 2_000)) {
103
+ || value.evidence !== value.evidence.trim() || !value.evidence || exceedsBlockerEvidenceLimit(value.evidence))) {
95
104
  return false;
96
105
  }
97
106
  if (status === "complete") return false;
@@ -155,12 +155,12 @@ async function pathExists(filePath: string): Promise<boolean> {
155
155
  }
156
156
 
157
157
  /** Installs generated guidance atomically and preserves any target changed after baseline capture. */
158
- export async function installInitAgentsFile(
158
+ export async function installInitAgentsFileWithRecovery(
159
159
  targetPath: string,
160
160
  content: string,
161
161
  baseline: InitTargetBaseline,
162
162
  operations: InitInstallOperations = {},
163
- ): Promise<void> {
163
+ ): Promise<string | undefined> {
164
164
  const validationError = validateGeneratedGuidance(content);
165
165
  if (validationError) throw new Error(validationError);
166
166
  const renameFile = operations.renameFile ?? fs.rename;
@@ -208,8 +208,8 @@ export async function installInitAgentsFile(
208
208
  return;
209
209
  }
210
210
 
211
- // Node cannot lock arbitrary external writers. The exclusive links, held-target
212
- // boundary, final held-file hash, and Pi mutation queue make installation fail closed.
211
+ // Node cannot lock arbitrary external writers, so keep the original inode named
212
+ // after commit. Writers with an open handle then remain recoverable.
213
213
  await renameFile(targetPath, heldPath);
214
214
  held = true;
215
215
  const moved = await captureExistingTarget(heldPath);
@@ -242,10 +242,12 @@ export async function installInitAgentsFile(
242
242
  if (!sameBaseline(finalHeld, baseline) || !await installedCandidateMatches(targetPath, candidate)) {
243
243
  throw new Error("/init target changed while /init was generating; the newer AGENTS.md was preserved");
244
244
  }
245
- await unlinkFile(heldPath);
245
+ retainedRecovery = recoveryPath(targetPath);
246
+ await renameFile(heldPath, retainedRecovery);
246
247
  held = false;
247
248
  await removeCandidateName(candidatePath, unlinkFile);
248
249
  installed = false;
250
+ return retainedRecovery;
249
251
  } catch (error) {
250
252
  if (held) {
251
253
  if (installed && candidate && await installedCandidateMatches(targetPath, candidate)) {
@@ -288,6 +290,15 @@ export async function installInitAgentsFile(
288
290
  });
289
291
  }
290
292
 
293
+ export async function installInitAgentsFile(
294
+ targetPath: string,
295
+ content: string,
296
+ baseline: InitTargetBaseline,
297
+ operations: InitInstallOperations = {},
298
+ ): Promise<void> {
299
+ await installInitAgentsFileWithRecovery(targetPath, content, baseline, operations);
300
+ }
301
+
291
302
  export async function writeInitAgentsFile(
292
303
  targetPath: string,
293
304
  content: string,
package/killeros/init.ts CHANGED
@@ -13,7 +13,7 @@ import {
13
13
  } from "./init-evidence.ts";
14
14
  import {
15
15
  captureInitTargetBaseline,
16
- installInitAgentsFile,
16
+ installInitAgentsFileWithRecovery,
17
17
  validateGeneratedGuidance,
18
18
  } from "./init-target.ts";
19
19
  import { resetInitRuntime, type GoalRuntime, type InitOutcome, type InitRuntime } from "./runtime.ts";
@@ -109,11 +109,12 @@ export function registerInitCommand(pi: ExtensionAPI, initState: InitRuntime, go
109
109
  if (!initState.targetPath || !initState.baseline) throw new Error("/init target baseline is unavailable");
110
110
  const validationError = validateGeneratedGuidance(content);
111
111
  if (validationError) throw new Error(validationError);
112
- await installInitAgentsFile(initState.targetPath, content, initState.baseline);
113
- initState.outcome = { kind: "written" };
112
+ const recoveryPath = await installInitAgentsFileWithRecovery(initState.targetPath, content, initState.baseline);
113
+ initState.outcome = { kind: "written", ...(recoveryPath ? { recoveryPath } : {}) };
114
+ const recoveryNotice = recoveryPath ? ` Previous AGENTS.md preserved at ${safeTerminalText(recoveryPath)}.` : "";
114
115
  return {
115
- content: [{ type: "text" as const, text: "Generated root AGENTS.md; read it once with killeros_init_read." }],
116
- details: { path: initState.targetPath },
116
+ content: [{ type: "text" as const, text: `Generated root AGENTS.md.${recoveryNotice} Read it once with killeros_init_read.` }],
117
+ details: { path: initState.targetPath, ...(recoveryPath ? { recoveryPath } : {}) },
117
118
  };
118
119
  },
119
120
  });
@@ -244,6 +245,9 @@ export function registerInitCommand(pi: ExtensionAPI, initState: InitRuntime, go
244
245
  const outcome = await settled;
245
246
  switch (outcome.kind) {
246
247
  case "written":
248
+ if (outcome.recoveryPath) {
249
+ ctx.ui.notify(`/init preserved the previous AGENTS.md at ${safeTerminalText(outcome.recoveryPath)}`, "info");
250
+ }
247
251
  await new Promise<void>((resolve) => setImmediate(resolve));
248
252
  try {
249
253
  await ctx.reload();
@@ -3,7 +3,7 @@ import type { InitTargetBaseline } from "./init-target.ts";
3
3
 
4
4
  export type InitOutcome =
5
5
  | { kind: "pending" }
6
- | { kind: "written" }
6
+ | { kind: "written"; recoveryPath?: string }
7
7
  | { kind: "policy-conflict"; reason: string }
8
8
  | { kind: "cancelled" }
9
9
  | { kind: "no-outcome" };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "killeros",
3
- "version": "2.1.25",
3
+ "version": "2.1.26",
4
4
  "description": "TUI, goals, and workflow automation for the Pi coding agent",
5
5
  "type": "module",
6
6
  "keywords": [