killeros 2.1.27 → 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.
@@ -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
+ }
@@ -1,5 +1,43 @@
1
1
  export type GoalStatus = "active" | "paused" | "blocked" | "complete";
2
2
 
3
+ export type GoalTurnPhase = "ready" | "in-flight" | "authorized";
4
+
5
+ export interface GoalContinueReport {
6
+ turn: number;
7
+ evidence: string;
8
+ nextAction: string;
9
+ }
10
+
11
+ export type GoalTurnDecision =
12
+ | {
13
+ kind: "continue";
14
+ turn: number;
15
+ evidence: string;
16
+ nextAction: string;
17
+ }
18
+ | {
19
+ kind: "blocker-audit";
20
+ turn: number;
21
+ blockerKey: string;
22
+ streak: number;
23
+ evidence: string;
24
+ }
25
+ | {
26
+ kind: "blocked";
27
+ turn: number;
28
+ blockerKey: string;
29
+ streak: 3;
30
+ evidence: string;
31
+ }
32
+ | {
33
+ kind: "complete";
34
+ turn: number;
35
+ evidence: string;
36
+ verification: "file" | "model-reported";
37
+ };
38
+
39
+ export type GoalPendingDecision = Extract<GoalTurnDecision, { kind: "continue" | "blocker-audit" }>;
40
+
3
41
  export interface GoalBlockerAudit {
4
42
  key: string;
5
43
  streak: number;
@@ -29,6 +67,11 @@ export interface GoalStateCommon {
29
67
  baselineTokens: number;
30
68
  verification?: GoalFileVerification;
31
69
  maxTurns?: number;
70
+ turnPhase?: GoalTurnPhase;
71
+ turnDecision?: GoalPendingDecision;
72
+ lastDecision?: GoalTurnDecision;
73
+ lastContinueReport?: GoalContinueReport;
74
+ stopReason?: string;
32
75
  }
33
76
 
34
77
  export type GoalState = GoalStateCommon & (
@@ -69,6 +112,13 @@ export interface AutomaticGoalCompaction {
69
112
  pausedRevision: number;
70
113
  outcome: AutomaticGoalCompactionOutcome;
71
114
  turnSettled: boolean;
115
+ turn: number;
116
+ resumeSameTurn: boolean;
117
+ }
118
+
119
+ export interface GoalTurnExecution {
120
+ turn: number;
121
+ revision: number;
72
122
  }
73
123
 
74
124
  export interface GoalRuntime {
@@ -77,10 +127,12 @@ export interface GoalRuntime {
77
127
  continuationHeld: boolean;
78
128
  goalTurnInFlight: boolean;
79
129
  agentEndObserved: boolean;
130
+ goalTurn?: GoalTurnExecution;
80
131
  automaticCompaction?: AutomaticGoalCompaction;
81
132
  persistenceRetryNeeded: boolean;
82
133
  lastStopReason?: string;
83
134
  lastError?: string;
135
+ lifecycleGeneration: number;
84
136
  requestRender?: () => void;
85
137
  }
86
138
 
@@ -90,7 +142,9 @@ export function createGoalRuntime(): GoalRuntime {
90
142
  continuationHeld: false,
91
143
  goalTurnInFlight: false,
92
144
  agentEndObserved: false,
145
+ goalTurn: undefined,
93
146
  automaticCompaction: undefined,
94
147
  persistenceRetryNeeded: false,
148
+ lifecycleGeneration: 0,
95
149
  };
96
150
  }
@@ -322,7 +322,7 @@ export function registerWorkedFor(
322
322
  pi.registerEntryRenderer<WorkedForEntryData>(WORKED_FOR_ENTRY_TYPE, (entry, options, theme) => {
323
323
  const data = parseWorkedForEntryData(entry.data);
324
324
  if (!data) return undefined;
325
- if (data.version === 1) return new Text(theme.fg("dim", `✻ Worked for ${formatWorkedForDuration(data.milliseconds)}`), 1, 0);
325
+ if (data.version === 1) return new Text(theme.fg("dim", `Worked for ${formatWorkedForDuration(data.milliseconds)}`), 1, 0);
326
326
  if (data.version === 4) return new WorkedForV4Component(data, options.expanded, theme);
327
327
  const outcome = OUTCOMES[data.outcome];
328
328
  const tokens = data.version === 3 ? ` · ↑ ${formatTokens(data.tokens)} tokens` : "";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "killeros",
3
- "version": "2.1.27",
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": [
@@ -16,7 +16,8 @@
16
16
  "success": "#8fa88b",
17
17
  "error": "#c8786c",
18
18
  "warning": "#bda36c",
19
- "pink": "#b98aa5"
19
+ "pink": "#b98aa5",
20
+ "teal": "#6FAEB2"
20
21
  },
21
22
  "colors": {
22
23
  "accent": "coral",
@@ -36,7 +37,7 @@
36
37
  "userMessageText": "text",
37
38
  "customMessageBg": "surface",
38
39
  "customMessageText": "text",
39
- "customMessageLabel": "coralBright",
40
+ "customMessageLabel": "teal",
40
41
  "toolPendingBg": "surface",
41
42
  "toolSuccessBg": "surface",
42
43
  "toolErrorBg": "surface",