killeros 2.1.25 → 2.1.27

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.
@@ -3,7 +3,6 @@ import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { StringDecoder } from "node:string_decoder";
5
5
  import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
- import type { InitRuntime } from "./runtime.ts";
7
6
 
8
7
  const PERSONAL_INSTRUCTIONS_FILE = "AGENTS.local.md";
9
8
  const PERSONAL_INSTRUCTIONS_LIMIT = 32 * 1024;
@@ -107,9 +106,9 @@ export function resolvePersonalInstructions(cwd: string): string | undefined {
107
106
  return `<personal_instructions>\n${content}\n</personal_instructions>`;
108
107
  }
109
108
 
110
- export function registerPersonalInstructions(pi: ExtensionAPI, initState: InitRuntime): void {
109
+ export function registerPersonalInstructions(pi: ExtensionAPI): void {
111
110
  pi.on("before_agent_start", (event, ctx) => {
112
- if (initState.active || !ctx.isProjectTrusted()) return;
111
+ if (!ctx.isProjectTrusted()) return;
113
112
  const personal = resolvePersonalInstructions(ctx.cwd);
114
113
  if (!personal) return;
115
114
  return {
@@ -1,25 +1,3 @@
1
- import type { InitEvidenceIndex } from "./init-evidence.ts";
2
- import type { InitTargetBaseline } from "./init-target.ts";
3
-
4
- export type InitOutcome =
5
- | { kind: "pending" }
6
- | { kind: "written" }
7
- | { kind: "policy-conflict"; reason: string }
8
- | { kind: "cancelled" }
9
- | { kind: "no-outcome" };
10
-
11
- export interface InitRuntime {
12
- active: boolean;
13
- starting?: symbol;
14
- targetPath?: string;
15
- projectRoot?: string;
16
- activeTools?: string[];
17
- evidence?: InitEvidenceIndex;
18
- baseline?: InitTargetBaseline;
19
- outcome: InitOutcome;
20
- settle?: (outcome: InitOutcome) => void;
21
- }
22
-
23
1
  export type GoalStatus = "active" | "paused" | "blocked" | "complete";
24
2
 
25
3
  export interface GoalBlockerAudit {
@@ -106,10 +84,6 @@ export interface GoalRuntime {
106
84
  requestRender?: () => void;
107
85
  }
108
86
 
109
- export function createInitRuntime(): InitRuntime {
110
- return { active: false, outcome: { kind: "pending" } };
111
- }
112
-
113
87
  export function createGoalRuntime(): GoalRuntime {
114
88
  return {
115
89
  continuationScheduled: false,
@@ -120,15 +94,3 @@ export function createGoalRuntime(): GoalRuntime {
120
94
  persistenceRetryNeeded: false,
121
95
  };
122
96
  }
123
-
124
- export function resetInitRuntime(state: InitRuntime): void {
125
- state.active = false;
126
- state.starting = undefined;
127
- state.targetPath = undefined;
128
- state.projectRoot = undefined;
129
- state.activeTools = undefined;
130
- state.evidence = undefined;
131
- state.baseline = undefined;
132
- state.outcome = { kind: "pending" };
133
- state.settle = undefined;
134
- }
@@ -24,6 +24,7 @@ import {
24
24
  type SlashCommandResolver,
25
25
  } from "./commands.ts";
26
26
  import { reportError } from "./errors.ts";
27
+ import { passiveGitCommand, passiveGitEnv } from "./passive-git-status.ts";
27
28
 
28
29
  function readPackageVersion(path: string | URL): string | undefined {
29
30
  try {
@@ -47,7 +48,6 @@ const STARTUP_TIPS = [
47
48
  "Type / to browse every command available in this session.",
48
49
  "Run /notification to enable a terminal bell when work settles.",
49
50
  "Run /goal <objective> to keep long-running work moving across turns.",
50
- "Run /init to generate root AGENTS.md from bounded repository evidence.",
51
51
  "Run /handoff [focus] to continue work in a fresh linked session.",
52
52
  "Run /codex-fast to toggle priority requests for Codex models.",
53
53
  "Run /clear to start a fresh session after confirmation.",
@@ -67,13 +67,22 @@ const EDITOR_SUGGESTIONS = [
67
67
  'Try "draft an implementation plan for <feature>"',
68
68
  ] as const;
69
69
 
70
- export function resolveGitBranch(cwd: string): Promise<string | undefined> {
70
+ export function resolveGitBranch(cwd: string, trusted = true): Promise<string | undefined> {
71
+ if (!trusted) return Promise.resolve(undefined);
72
+ let gitCommand: string | undefined;
73
+ try {
74
+ gitCommand = passiveGitCommand(cwd);
75
+ } catch {
76
+ return Promise.resolve(undefined);
77
+ }
78
+ if (!gitCommand) return Promise.resolve(undefined);
71
79
  return new Promise((resolve) => {
72
80
  execFile(
73
- "git",
81
+ gitCommand,
74
82
  ["-C", cwd, "rev-parse", "--abbrev-ref", "HEAD"],
75
83
  {
76
84
  encoding: "utf8",
85
+ env: passiveGitEnv(),
77
86
  maxBuffer: 64 * 1024,
78
87
  timeout: 500,
79
88
  windowsHide: true,
@@ -132,7 +141,7 @@ class PiStartupHeader {
132
141
  this.ctx = ctx;
133
142
  this.tip = tip;
134
143
  this.tui = tui;
135
- void resolveGitBranch(ctx.cwd).then((branch) => {
144
+ void resolveGitBranch(ctx.cwd, ctx.isProjectTrusted()).then((branch) => {
136
145
  if (this.disposed) return;
137
146
  this.branch = branch;
138
147
  this.tui.requestRender();
@@ -142,7 +142,8 @@ function parseV4(data: Record<string, unknown>): WorkedForEntryDataV4 | undefine
142
142
  const checks: CheckAttempt[] = [];
143
143
  for (const check of data.checks) {
144
144
  if (!record(check) || check.outcome !== "passed" && check.outcome !== "failed") return undefined;
145
- const label = CHECK_LABELS.find((candidate) => candidate === check.label);
145
+ const label = CHECK_LABELS.find((candidate) => candidate === check.label)
146
+ ?? (check.label === "node --test (focused)" ? "node --test (focused)" : undefined);
146
147
  if (!label) return undefined;
147
148
  checks.push({ label, outcome: check.outcome });
148
149
  }
@@ -337,7 +338,7 @@ export function registerWorkedFor(
337
338
  });
338
339
 
339
340
  pi.on("agent_start", async (_event, ctx) => {
340
- if (ctx.mode !== "tui" || active) return;
341
+ if (ctx.mode !== "tui" || active || !ctx.isProjectTrusted()) return;
341
342
  const state: ActiveReceipt = {
342
343
  startedAt: now(),
343
344
  startedTokens: sessionTokenTotal(ctx),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "killeros",
3
- "version": "2.1.25",
3
+ "version": "2.1.27",
4
4
  "description": "TUI, goals, and workflow automation for the Pi coding agent",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -1,291 +0,0 @@
1
- import { spawn } from "node:child_process";
2
- import { promises as fs } from "node:fs";
3
- import path from "node:path";
4
- import { StringDecoder } from "node:string_decoder";
5
- import { containsLikelySecret } from "./secret-detector.ts";
6
-
7
- export const INIT_READ_TOOL = "killeros_init_read";
8
- export const INIT_LIST_TOOL = "killeros_init_list";
9
-
10
- const SNAPSHOT_LIMIT = 40 * 1024;
11
- const AUTOMATIC_FILE_LIMIT = 8 * 1024;
12
- const READ_LIMIT = 32 * 1024;
13
- const PATH_LIMIT = 400;
14
- const DIRECTORY_LIMIT = 120;
15
- const DEPTH_LIMIT = 4;
16
- const EXCLUDED_DIRS = new Set([
17
- ".agents", ".claude", ".git", ".next", ".pi", ".pytest_cache", ".turbo", ".venv", "__pycache__", "archive", "build", "coverage", "data", "dist", "logs", "node_modules", "target", "test-results", "vendor",
18
- ]);
19
- const EXCLUDED_GUIDANCE = new Set([
20
- ".cursorrules", "agents.md", "agents.local.md", "claude.md", "claude.local.md", "copilot-instructions.md", "gemini.md", "memory.md", "skill.md",
21
- ]);
22
- const ROOT_EVIDENCE = [
23
- "README.md", "README.rst", "README.txt", "CONTRIBUTING.md", "package.json", "pyproject.toml", "requirements.txt", "Cargo.toml", "go.mod", "Makefile", "Dockerfile", "compose.yaml", "compose.yml", "tsconfig.json", "vite.config.ts", "vite.config.js", "eslint.config.js", "eslint.config.mjs",
24
- ] as const;
25
- const NESTED_EVIDENCE = new Set(["package.json", "pyproject.toml", "requirements.txt", "Cargo.toml", "go.mod"]);
26
-
27
- export interface InitEvidenceIndex {
28
- projectRoot: string;
29
- canonicalPaths: ReadonlyMap<string, string>;
30
- snapshot: string;
31
- }
32
-
33
- export interface InitEvidenceBuildResult {
34
- index: InitEvidenceIndex;
35
- }
36
-
37
- function evidenceKey(relativePath: string): string {
38
- const normalized = relativePath.replaceAll("\\", "/");
39
- return process.platform === "win32" ? normalized.toLowerCase() : normalized;
40
- }
41
-
42
- function sensitiveEvidencePath(relativePath: string): boolean {
43
- const normalized = relativePath.replaceAll("\\", "/").toLowerCase();
44
- const name = path.posix.basename(normalized);
45
- return /^\.env(?:\.|$)/u.test(name)
46
- || [".npmrc", ".pypirc", ".netrc", "id_rsa", "id_ed25519", "credentials.json"].includes(name)
47
- || /(?:^|\/)\.aws\/credentials$/u.test(normalized)
48
- || /(?:^|\/)\.docker\/config\.json$/u.test(normalized)
49
- || /(?:^|\/)\.kube\/config$/u.test(normalized)
50
- || /(?:credential|secret|token)/u.test(name)
51
- || /^service-account.*\.json$/u.test(name)
52
- || /\.(?:pem|key|p12|pfx|jks|keystore)$/u.test(name);
53
- }
54
-
55
- function excludedPath(relativePath: string): boolean {
56
- const segments = relativePath.replaceAll("\\", "/").split("/");
57
- return segments.some((segment, index) =>
58
- (index < segments.length - 1 && EXCLUDED_DIRS.has(segment.toLowerCase()))
59
- || EXCLUDED_GUIDANCE.has(segment.toLowerCase()))
60
- || sensitiveEvidencePath(relativePath);
61
- }
62
-
63
- async function collectCandidates(projectRoot: string): Promise<string[]> {
64
- const files: string[] = [];
65
- const queue: Array<{ relativePath: string; depth: number }> = [{ relativePath: "", depth: 0 }];
66
- let directoriesRead = 0;
67
- while (queue.length && files.length < PATH_LIMIT && directoriesRead < DIRECTORY_LIMIT) {
68
- const current = queue.shift();
69
- if (!current) break;
70
- directoriesRead += 1;
71
- let entries;
72
- try {
73
- entries = await fs.readdir(path.join(projectRoot, current.relativePath), { withFileTypes: true });
74
- } catch (error) {
75
- if (!current.relativePath) throw error;
76
- continue;
77
- }
78
- entries.sort((left, right) => left.name.localeCompare(right.name));
79
- for (const entry of entries) {
80
- if (files.length >= PATH_LIMIT) break;
81
- const relativePath = path.posix.join(current.relativePath.replaceAll("\\", "/"), entry.name);
82
- if (entry.isDirectory()) {
83
- if (current.depth < DEPTH_LIMIT && !EXCLUDED_DIRS.has(entry.name.toLowerCase())) {
84
- queue.push({ relativePath, depth: current.depth + 1 });
85
- }
86
- } else if (entry.isFile() && !excludedPath(relativePath)) {
87
- try {
88
- const stat = await fs.lstat(path.join(projectRoot, relativePath));
89
- if (stat.isFile() && !stat.isSymbolicLink() && stat.nlink === 1) files.push(relativePath);
90
- } catch {
91
- // Files may disappear while the bounded map is collected.
92
- }
93
- }
94
- }
95
- }
96
- return files;
97
- }
98
-
99
- async function gitIgnoredPaths(projectRoot: string, candidates: readonly string[]): Promise<ReadonlySet<string>> {
100
- if (!candidates.length) return new Set();
101
- return new Promise((resolve, reject) => {
102
- let settled = false;
103
- let stdout = Buffer.alloc(0);
104
- const child = spawn("git", ["-C", projectRoot, "check-ignore", "--stdin", "-z"], {
105
- shell: false,
106
- stdio: ["pipe", "pipe", "ignore"],
107
- windowsHide: true,
108
- });
109
- const fail = (): void => {
110
- if (settled) return;
111
- settled = true;
112
- clearTimeout(timer);
113
- reject(new Error("Git ignore inspection failed; /init did not build repository evidence"));
114
- };
115
- const succeed = (value: ReadonlySet<string>): void => {
116
- if (settled) return;
117
- settled = true;
118
- clearTimeout(timer);
119
- resolve(value);
120
- };
121
- const timer = setTimeout(() => {
122
- child.kill("SIGKILL");
123
- fail();
124
- }, 2_000);
125
- child.stdout.on("data", (chunk: Buffer) => {
126
- if (stdout.length + chunk.length > 256 * 1024) {
127
- child.kill("SIGKILL");
128
- fail();
129
- return;
130
- }
131
- stdout = Buffer.concat([stdout, chunk]);
132
- });
133
- child.once("error", fail);
134
- child.stdin.once("error", () => {
135
- child.kill("SIGKILL");
136
- fail();
137
- });
138
- child.once("close", (code) => {
139
- if (code === 1) {
140
- if (stdout.length) fail();
141
- else succeed(new Set());
142
- return;
143
- }
144
- if (code !== 0 || !stdout.length || stdout.at(-1) !== 0) {
145
- fail();
146
- return;
147
- }
148
- let values: string[];
149
- try {
150
- values = new TextDecoder("utf-8", { fatal: true }).decode(stdout.subarray(0, -1)).split("\0");
151
- } catch {
152
- fail();
153
- return;
154
- }
155
- const candidateSet = new Set(candidates.map(evidenceKey));
156
- const ignored = new Set(values.map(evidenceKey));
157
- if (values.some((value) => !value || !candidateSet.has(evidenceKey(value))) || ignored.size !== values.length) {
158
- fail();
159
- return;
160
- }
161
- succeed(ignored);
162
- });
163
- child.stdin.end(`${candidates.join("\0")}\0`);
164
- });
165
- }
166
-
167
- function decodeCompleteUtf8(bytes: Buffer): string {
168
- return new StringDecoder("utf8").write(bytes);
169
- }
170
-
171
- function appendWithinLimit(current: string, section: string, limit: number): string {
172
- const remaining = limit - Buffer.byteLength(current, "utf8");
173
- if (remaining <= 0) return current;
174
- const bytes = Buffer.from(section, "utf8");
175
- return current + decodeCompleteUtf8(bytes.subarray(0, remaining));
176
- }
177
-
178
- async function validateAndRead(projectRoot: string, absolutePath: string, limit: number): Promise<{ content: string; truncated: boolean }> {
179
- const relative = path.relative(projectRoot, absolutePath);
180
- if (!relative || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
181
- throw new Error("path is not available to /init");
182
- }
183
- let current = projectRoot;
184
- for (const segment of relative.split(path.sep)) {
185
- current = path.join(current, segment);
186
- const stat = await fs.lstat(current);
187
- if (stat.isSymbolicLink()) throw new Error("/init rejects symbolic-link and junction paths");
188
- }
189
- const pathStat = await fs.lstat(absolutePath);
190
- if (!pathStat.isFile() || pathStat.nlink !== 1) throw new Error("/init rejects linked and non-regular files");
191
- const handle = await fs.open(absolutePath, "r");
192
- try {
193
- const openedStat = await handle.stat();
194
- if (!openedStat.isFile() || openedStat.nlink !== 1
195
- || openedStat.dev !== pathStat.dev || openedStat.ino !== pathStat.ino) {
196
- throw new Error("/init file changed while it was being opened");
197
- }
198
- const buffer = Buffer.alloc(limit + 1);
199
- const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
200
- const data = buffer.subarray(0, Math.min(bytesRead, limit));
201
- if (data.includes(0)) throw new Error("/init rejects binary files");
202
- return { content: decodeCompleteUtf8(data), truncated: bytesRead > limit };
203
- } finally {
204
- await handle.close();
205
- }
206
- }
207
-
208
- /** Reads evidence only when its bounded content does not resemble a credential. */
209
- async function readEvidenceFile(projectRoot: string, absolutePath: string, limit: number): Promise<{ content: string; truncated: boolean }> {
210
- const result = await validateAndRead(projectRoot, absolutePath, limit);
211
- if (containsLikelySecret(result.content)) throw new Error("file is not available to /init");
212
- return result;
213
- }
214
-
215
- function normalizeRequestedPath(requestedPath: string): string {
216
- if (!requestedPath || requestedPath.trim() !== requestedPath || requestedPath.startsWith("~")
217
- || /^file:/iu.test(requestedPath) || path.isAbsolute(requestedPath)) {
218
- throw new Error("path is not available to /init");
219
- }
220
- const normalized = requestedPath.replaceAll("\\", "/");
221
- if (!normalized || normalized.split("/").some((segment) => segment === ".." || segment === "")) {
222
- throw new Error("path is not available to /init");
223
- }
224
- return normalized.replace(/^\.\//u, "");
225
- }
226
-
227
- export async function buildInitEvidence(projectRoot: string): Promise<InitEvidenceBuildResult> {
228
- const candidates = await collectCandidates(projectRoot);
229
- const ignored = await gitIgnoredPaths(projectRoot, candidates);
230
- const canonicalPaths = new Map<string, string>();
231
- for (const relativePath of candidates) {
232
- if (!ignored.has(evidenceKey(relativePath))) canonicalPaths.set(evidenceKey(relativePath), relativePath);
233
- }
234
-
235
- let snapshot = [
236
- "# KillerOS repository snapshot",
237
- "Root AGENTS.md is protected policy and is intentionally not part of this untrusted evidence.",
238
- "",
239
- "## Project files",
240
- [...canonicalPaths.values()].join("\n"),
241
- ].join("\n");
242
- snapshot = appendWithinLimit("", snapshot, SNAPSHOT_LIMIT);
243
- const automatic = new Set<string>(ROOT_EVIDENCE);
244
- for (const relativePath of canonicalPaths.values()) {
245
- const name = path.posix.basename(relativePath);
246
- if (NESTED_EVIDENCE.has(name) || /^\.github\/workflows\/[^/]+\.ya?ml$/iu.test(relativePath)) automatic.add(relativePath);
247
- }
248
- for (const requested of automatic) {
249
- const relativePath = canonicalPaths.get(evidenceKey(requested));
250
- if (!relativePath || Buffer.byteLength(snapshot, "utf8") >= SNAPSHOT_LIMIT) continue;
251
- try {
252
- const result = await readEvidenceFile(projectRoot, path.join(projectRoot, relativePath), AUTOMATIC_FILE_LIMIT);
253
- const suffix = result.truncated ? "\n[truncated by /init]" : "";
254
- snapshot = appendWithinLimit(snapshot, `\n\n## ${relativePath}\n${result.content}${suffix}`, SNAPSHOT_LIMIT);
255
- } catch {
256
- // A mapped file may become unsafe or disappear before snapshot creation.
257
- }
258
- }
259
- return { index: { projectRoot, canonicalPaths, snapshot } };
260
- }
261
-
262
- export async function readInitEvidence(index: InitEvidenceIndex, requestedPath: string): Promise<string> {
263
- const normalized = normalizeRequestedPath(requestedPath);
264
- const relativePath = index.canonicalPaths.get(evidenceKey(normalized));
265
- if (!relativePath) throw new Error(`${requestedPath} is not available to /init`);
266
- const result = await readEvidenceFile(index.projectRoot, path.join(index.projectRoot, relativePath), READ_LIMIT);
267
- return result.truncated ? `${result.content}\n[truncated by /init at ${READ_LIMIT} bytes]` : result.content;
268
- }
269
-
270
- export async function readGeneratedInitTarget(projectRoot: string, targetPath: string): Promise<string> {
271
- const result = await validateAndRead(projectRoot, targetPath, READ_LIMIT);
272
- return result.truncated ? `${result.content}\n[truncated by /init at ${READ_LIMIT} bytes]` : result.content;
273
- }
274
-
275
- export function listInitEvidence(index: InitEvidenceIndex, requestedPath = "."): string[] {
276
- const prefix = requestedPath === "." ? "" : normalizeRequestedPath(requestedPath).replace(/\/$/u, "");
277
- const prefixWithSlash = prefix ? `${prefix}/` : "";
278
- const evidencePrefix = evidenceKey(prefixWithSlash);
279
- const children = new Set<string>();
280
- let found = !prefix;
281
- for (const relativePath of index.canonicalPaths.values()) {
282
- if (!evidenceKey(relativePath).startsWith(evidencePrefix)) continue;
283
- const remainder = relativePath.slice(prefixWithSlash.length);
284
- if (!remainder) continue;
285
- found = true;
286
- const child = remainder.split("/", 1)[0];
287
- if (child) children.add(child);
288
- }
289
- if (!found) throw new Error(`${requestedPath} is not available to /init`);
290
- return [...children].sort((left, right) => left.localeCompare(right)).slice(0, PATH_LIMIT);
291
- }