killeros 2.0.3 → 2.0.5

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/killeros/hooks.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { existsSync, readFileSync } from "node:fs";
3
3
  import path from "node:path";
4
+ import { StringDecoder } from "node:string_decoder";
4
5
  import { CONFIG_DIR_NAME, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
5
6
  import { reportError } from "./errors.ts";
6
7
  import { MAX_NODE_TIMER_MS } from "./limits.ts";
@@ -22,6 +23,7 @@ interface HookExecutionResult {
22
23
  stdout: string;
23
24
  stderr: string;
24
25
  timedOut: boolean;
26
+ cancelled: boolean;
25
27
  exitUnconfirmed: boolean;
26
28
  }
27
29
 
@@ -43,6 +45,10 @@ function loadKillerosHooks(ctx: ExtensionContext): KillerosHookConfig {
43
45
  const candidates = parsed.hooks?.[event];
44
46
  if (!Array.isArray(candidates)) continue;
45
47
  hooks[event] = candidates.filter((hook, index) => {
48
+ if (event === "agent_settled" && hook?.matcher !== undefined) {
49
+ ctx.ui.notify(`Ignored ${event} hook ${index + 1}: matchers are only valid for tool events`, "warning");
50
+ return false;
51
+ }
46
52
  const valid = hook
47
53
  && typeof hook.command === "string"
48
54
  && hook.command.trim().length > 0
@@ -79,9 +85,19 @@ function matchesHook(hook: KillerosHook, value: string): boolean {
79
85
  }
80
86
  }
81
87
 
82
- function appendBounded(current: string, chunk: Buffer | string): string {
83
- if (current.length >= HOOK_OUTPUT_LIMIT) return current;
84
- return (current + chunk.toString()).slice(0, HOOK_OUTPUT_LIMIT);
88
+ interface HookOutputBuffer {
89
+ bytes: number;
90
+ decoder: StringDecoder;
91
+ text: string;
92
+ }
93
+
94
+ function appendBounded(output: HookOutputBuffer, chunk: Buffer | string): void {
95
+ const remaining = HOOK_OUTPUT_LIMIT - output.bytes;
96
+ if (remaining <= 0) return;
97
+ const bytes = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk;
98
+ const captured = bytes.subarray(0, remaining);
99
+ output.bytes += captured.length;
100
+ output.text += output.decoder.write(captured);
85
101
  }
86
102
 
87
103
  function terminateHookProcess(child: ReturnType<typeof spawn>, force: boolean): void {
@@ -109,7 +125,17 @@ function terminateHookProcess(child: ReturnType<typeof spawn>, force: boolean):
109
125
  }
110
126
  }
111
127
 
112
- export function executeHook(command: string, cwd: string, environment: Record<string, string>, timeoutMs = 30_000, spawnProcess: typeof spawn = spawn): Promise<HookExecutionResult> {
128
+ export function executeHook(
129
+ command: string,
130
+ cwd: string,
131
+ environment: Record<string, string>,
132
+ timeoutMs = 30_000,
133
+ spawnProcess: typeof spawn = spawn,
134
+ signal?: AbortSignal,
135
+ ): Promise<HookExecutionResult> {
136
+ if (signal?.aborted) {
137
+ return Promise.resolve({ code: 130, stdout: "", stderr: "", timedOut: false, cancelled: true, exitUnconfirmed: false });
138
+ }
113
139
  return new Promise((resolve) => {
114
140
  const child = spawnProcess(command, {
115
141
  cwd,
@@ -119,39 +145,51 @@ export function executeHook(command: string, cwd: string, environment: Record<st
119
145
  stdio: ["ignore", "pipe", "pipe"],
120
146
  windowsHide: true,
121
147
  });
122
- let stdout = "";
123
- let stderr = "";
148
+ const stdout: HookOutputBuffer = { bytes: 0, decoder: new StringDecoder("utf8"), text: "" };
149
+ const stderr: HookOutputBuffer = { bytes: 0, decoder: new StringDecoder("utf8"), text: "" };
124
150
  let completed = false;
125
- let timedOut = false;
126
- let exitUnconfirmed = false;
151
+ let termination: "timeout" | "cancelled" | undefined;
127
152
  let timer: NodeJS.Timeout | undefined;
128
153
  let forceTimer: NodeJS.Timeout | undefined;
129
154
  let settleTimer: NodeJS.Timeout | undefined;
130
- const finish = (code: number, unconfirmed = false): void => {
155
+ const finish = (code: number, exitUnconfirmed = false): void => {
131
156
  if (completed) return;
132
157
  completed = true;
133
- exitUnconfirmed = unconfirmed;
134
158
  if (timer) clearTimeout(timer);
135
159
  if (forceTimer) clearTimeout(forceTimer);
136
160
  if (settleTimer) clearTimeout(settleTimer);
137
- resolve({ code, stdout, stderr, timedOut, exitUnconfirmed });
161
+ signal?.removeEventListener("abort", abort);
162
+ resolve({
163
+ code,
164
+ stdout: stdout.text,
165
+ stderr: stderr.text,
166
+ timedOut: termination === "timeout",
167
+ cancelled: termination === "cancelled",
168
+ exitUnconfirmed,
169
+ });
138
170
  };
139
- child.stdout.on("data", (chunk) => { stdout = appendBounded(stdout, chunk); });
140
- child.stderr.on("data", (chunk) => { stderr = appendBounded(stderr, chunk); });
141
- child.on("error", (error) => {
142
- stderr = appendBounded(stderr, error.message);
143
- finish(timedOut ? 124 : 1);
144
- });
145
- child.once("close", (code) => finish(timedOut ? 124 : code ?? 1));
146
- timer = setTimeout(() => {
147
- timedOut = true;
171
+ const terminationCode = (): number => termination === "cancelled" ? 130 : 124;
172
+ const beginTermination = (reason: "timeout" | "cancelled"): void => {
173
+ if (completed || termination) return;
174
+ termination = reason;
148
175
  terminateHookProcess(child, false);
149
176
  forceTimer = setTimeout(() => {
150
177
  if (completed) return;
151
178
  terminateHookProcess(child, true);
152
- settleTimer = setTimeout(() => finish(124, true), 1_000);
179
+ settleTimer = setTimeout(() => finish(terminationCode(), true), 1_000);
153
180
  }, 1_000);
154
- }, Math.max(1_000, Math.min(timeoutMs, 300_000)));
181
+ };
182
+ const abort = (): void => beginTermination("cancelled");
183
+ signal?.addEventListener("abort", abort, { once: true });
184
+ if (signal?.aborted) beginTermination("cancelled");
185
+ child.stdout.on("data", (chunk) => appendBounded(stdout, chunk));
186
+ child.stderr.on("data", (chunk) => appendBounded(stderr, chunk));
187
+ child.on("error", (error) => {
188
+ appendBounded(stderr, error.message);
189
+ finish(termination ? terminationCode() : 1);
190
+ });
191
+ child.once("close", (code) => finish(termination ? terminationCode() : code ?? 1));
192
+ timer = setTimeout(() => beginTermination("timeout"), Math.max(1_000, Math.min(timeoutMs, 300_000)));
155
193
  });
156
194
  }
157
195
 
@@ -180,7 +218,10 @@ export function registerLifecycleHooks(pi: ExtensionAPI): void {
180
218
  ctx.cwd,
181
219
  hookEnvironment("tool_call", event.toolName, event.input),
182
220
  hook.timeoutMs,
221
+ spawn,
222
+ ctx.signal,
183
223
  );
224
+ if (result.cancelled) return { block: true, reason: "Hook cancelled because the parent request was aborted" };
184
225
  if (result.code !== 0) {
185
226
  const reason = hookFailureMessage(hook, result);
186
227
  ctx.ui.notify(reason, "error");
@@ -200,7 +241,10 @@ export function registerLifecycleHooks(pi: ExtensionAPI): void {
200
241
  isError: event.isError,
201
242
  }),
202
243
  hook.timeoutMs,
244
+ spawn,
245
+ ctx.signal,
203
246
  );
247
+ if (result.cancelled) break;
204
248
  if (result.code !== 0) ctx.ui.notify(hookFailureMessage(hook, result), "error");
205
249
  }
206
250
  });
@@ -212,7 +256,10 @@ export function registerLifecycleHooks(pi: ExtensionAPI): void {
212
256
  ctx.cwd,
213
257
  hookEnvironment("agent_settled"),
214
258
  hook.timeoutMs,
259
+ spawn,
260
+ ctx.signal,
215
261
  );
262
+ if (result.cancelled) break;
216
263
  if (result.code !== 0) ctx.ui.notify(hookFailureMessage(hook, result), "error");
217
264
  }
218
265
  });
@@ -0,0 +1,276 @@
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
+
6
+ export const INIT_READ_TOOL = "killeros_init_read";
7
+ export const INIT_LIST_TOOL = "killeros_init_list";
8
+
9
+ const SNAPSHOT_LIMIT = 40 * 1024;
10
+ const AUTOMATIC_FILE_LIMIT = 8 * 1024;
11
+ const READ_LIMIT = 32 * 1024;
12
+ const PATH_LIMIT = 400;
13
+ const DIRECTORY_LIMIT = 120;
14
+ const DEPTH_LIMIT = 4;
15
+ const EXCLUDED_DIRS = new Set([
16
+ ".agents", ".claude", ".git", ".next", ".pi", ".pytest_cache", ".turbo", ".venv", "__pycache__", "archive", "build", "coverage", "data", "dist", "logs", "node_modules", "target", "test-results", "vendor",
17
+ ]);
18
+ const EXCLUDED_GUIDANCE = new Set([
19
+ ".cursorrules", "agents.md", "agents.local.md", "claude.md", "claude.local.md", "copilot-instructions.md", "gemini.md", "memory.md", "skill.md",
20
+ ]);
21
+ const ROOT_EVIDENCE = [
22
+ "README.md", "README.rst", "README.txt", "CONTRIBUTING.md", "package.json", "pyproject.toml", "requirements.txt", "Cargo.toml", "go.mod", "Makefile", "Dockerfile", "compose.yaml", "compose.yml", "config.yaml", "config.yml", "tsconfig.json", "vite.config.ts", "vite.config.js", "eslint.config.js", "eslint.config.mjs",
23
+ ] as const;
24
+ const NESTED_EVIDENCE = new Set(["package.json", "pyproject.toml", "requirements.txt", "Cargo.toml", "go.mod"]);
25
+
26
+ export interface InitEvidenceIndex {
27
+ projectRoot: string;
28
+ canonicalPaths: ReadonlyMap<string, string>;
29
+ snapshot: string;
30
+ }
31
+
32
+ export interface InitEvidenceBuildResult {
33
+ index: InitEvidenceIndex;
34
+ }
35
+
36
+ function evidenceKey(relativePath: string): string {
37
+ const normalized = relativePath.replaceAll("\\", "/");
38
+ return process.platform === "win32" ? normalized.toLocaleLowerCase() : normalized;
39
+ }
40
+
41
+ function sensitiveEvidencePath(relativePath: string): boolean {
42
+ const normalized = relativePath.replaceAll("\\", "/").toLocaleLowerCase();
43
+ const name = path.posix.basename(normalized);
44
+ return /^\.env(?:\.|$)/u.test(name)
45
+ || [".npmrc", ".pypirc", ".netrc", "id_rsa", "id_ed25519", "credentials.json"].includes(name)
46
+ || /^service-account.*\.json$/u.test(name)
47
+ || /\.(?:pem|key|p12|pfx|jks|keystore)$/u.test(name);
48
+ }
49
+
50
+ function excludedPath(relativePath: string): boolean {
51
+ const segments = relativePath.replaceAll("\\", "/").split("/");
52
+ return segments.some((segment, index) =>
53
+ (index < segments.length - 1 && EXCLUDED_DIRS.has(segment.toLocaleLowerCase()))
54
+ || EXCLUDED_GUIDANCE.has(segment.toLocaleLowerCase()))
55
+ || sensitiveEvidencePath(relativePath);
56
+ }
57
+
58
+ async function collectCandidates(projectRoot: string): Promise<string[]> {
59
+ const files: string[] = [];
60
+ const queue: Array<{ relativePath: string; depth: number }> = [{ relativePath: "", depth: 0 }];
61
+ let directoriesRead = 0;
62
+ while (queue.length && files.length < PATH_LIMIT && directoriesRead < DIRECTORY_LIMIT) {
63
+ const current = queue.shift()!;
64
+ directoriesRead += 1;
65
+ let entries;
66
+ try {
67
+ entries = await fs.readdir(path.join(projectRoot, current.relativePath), { withFileTypes: true });
68
+ } catch (error) {
69
+ if (!current.relativePath) throw error;
70
+ continue;
71
+ }
72
+ entries.sort((left, right) => left.name.localeCompare(right.name));
73
+ for (const entry of entries) {
74
+ if (files.length >= PATH_LIMIT) break;
75
+ const relativePath = path.posix.join(current.relativePath.replaceAll("\\", "/"), entry.name);
76
+ if (entry.isDirectory()) {
77
+ if (current.depth < DEPTH_LIMIT && !EXCLUDED_DIRS.has(entry.name.toLocaleLowerCase())) {
78
+ queue.push({ relativePath, depth: current.depth + 1 });
79
+ }
80
+ } else if (entry.isFile() && !excludedPath(relativePath)) {
81
+ try {
82
+ const stat = await fs.lstat(path.join(projectRoot, relativePath));
83
+ if (stat.isFile() && !stat.isSymbolicLink() && stat.nlink === 1) files.push(relativePath);
84
+ } catch {
85
+ // Files may disappear while the bounded map is collected.
86
+ }
87
+ }
88
+ }
89
+ }
90
+ return files;
91
+ }
92
+
93
+ async function gitIgnoredPaths(projectRoot: string, candidates: readonly string[]): Promise<ReadonlySet<string>> {
94
+ if (!candidates.length) return new Set();
95
+ return new Promise((resolve, reject) => {
96
+ let settled = false;
97
+ let stdout = Buffer.alloc(0);
98
+ const child = spawn("git", ["-C", projectRoot, "check-ignore", "--stdin", "-z"], {
99
+ shell: false,
100
+ stdio: ["pipe", "pipe", "ignore"],
101
+ windowsHide: true,
102
+ });
103
+ const fail = (): void => {
104
+ if (settled) return;
105
+ settled = true;
106
+ clearTimeout(timer);
107
+ reject(new Error("Git ignore inspection failed; /init did not build repository evidence"));
108
+ };
109
+ const succeed = (value: ReadonlySet<string>): void => {
110
+ if (settled) return;
111
+ settled = true;
112
+ clearTimeout(timer);
113
+ resolve(value);
114
+ };
115
+ const timer = setTimeout(() => {
116
+ child.kill("SIGKILL");
117
+ fail();
118
+ }, 2_000);
119
+ child.stdout.on("data", (chunk: Buffer) => {
120
+ if (stdout.length + chunk.length > 256 * 1024) {
121
+ child.kill("SIGKILL");
122
+ fail();
123
+ return;
124
+ }
125
+ stdout = Buffer.concat([stdout, chunk]);
126
+ });
127
+ child.once("error", fail);
128
+ child.stdin.once("error", () => {
129
+ child.kill("SIGKILL");
130
+ fail();
131
+ });
132
+ child.once("close", (code) => {
133
+ if (code === 1) {
134
+ if (stdout.length) fail();
135
+ else succeed(new Set());
136
+ return;
137
+ }
138
+ if (code !== 0 || !stdout.length || stdout.at(-1) !== 0) {
139
+ fail();
140
+ return;
141
+ }
142
+ let values: string[];
143
+ try {
144
+ values = new TextDecoder("utf-8", { fatal: true }).decode(stdout.subarray(0, -1)).split("\0");
145
+ } catch {
146
+ fail();
147
+ return;
148
+ }
149
+ const candidateSet = new Set(candidates.map(evidenceKey));
150
+ const ignored = new Set(values.map(evidenceKey));
151
+ if (values.some((value) => !value || !candidateSet.has(evidenceKey(value))) || ignored.size !== values.length) {
152
+ fail();
153
+ return;
154
+ }
155
+ succeed(ignored);
156
+ });
157
+ child.stdin.end(`${candidates.join("\0")}\0`);
158
+ });
159
+ }
160
+
161
+ function decodeCompleteUtf8(bytes: Buffer): string {
162
+ return new StringDecoder("utf8").write(bytes);
163
+ }
164
+
165
+ function appendWithinLimit(current: string, section: string, limit: number): string {
166
+ const remaining = limit - Buffer.byteLength(current, "utf8");
167
+ if (remaining <= 0) return current;
168
+ const bytes = Buffer.from(section, "utf8");
169
+ return current + decodeCompleteUtf8(bytes.subarray(0, remaining));
170
+ }
171
+
172
+ async function validateAndRead(projectRoot: string, absolutePath: string, limit: number): Promise<{ content: string; truncated: boolean }> {
173
+ const relative = path.relative(projectRoot, absolutePath);
174
+ if (!relative || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
175
+ throw new Error("path is not available to /init");
176
+ }
177
+ let current = projectRoot;
178
+ for (const segment of relative.split(path.sep)) {
179
+ current = path.join(current, segment);
180
+ const stat = await fs.lstat(current);
181
+ if (stat.isSymbolicLink()) throw new Error("/init rejects symbolic-link and junction paths");
182
+ }
183
+ const pathStat = await fs.lstat(absolutePath);
184
+ if (!pathStat.isFile() || pathStat.nlink !== 1) throw new Error("/init rejects linked and non-regular files");
185
+ const handle = await fs.open(absolutePath, "r");
186
+ try {
187
+ const openedStat = await handle.stat();
188
+ if (!openedStat.isFile() || openedStat.nlink !== 1
189
+ || openedStat.dev !== pathStat.dev || openedStat.ino !== pathStat.ino) {
190
+ throw new Error("/init file changed while it was being opened");
191
+ }
192
+ const buffer = Buffer.alloc(limit + 1);
193
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
194
+ const data = buffer.subarray(0, Math.min(bytesRead, limit));
195
+ if (data.includes(0)) throw new Error("/init rejects binary files");
196
+ return { content: decodeCompleteUtf8(data), truncated: bytesRead > limit };
197
+ } finally {
198
+ await handle.close();
199
+ }
200
+ }
201
+
202
+ function normalizeRequestedPath(requestedPath: string): string {
203
+ if (!requestedPath || requestedPath.trim() !== requestedPath || requestedPath.startsWith("~")
204
+ || /^file:/iu.test(requestedPath) || path.isAbsolute(requestedPath)) {
205
+ throw new Error("path is not available to /init");
206
+ }
207
+ const normalized = requestedPath.replaceAll("\\", "/");
208
+ if (!normalized || normalized.split("/").some((segment) => segment === ".." || segment === "")) {
209
+ throw new Error("path is not available to /init");
210
+ }
211
+ return normalized.replace(/^\.\//u, "");
212
+ }
213
+
214
+ export async function buildInitEvidence(projectRoot: string): Promise<InitEvidenceBuildResult> {
215
+ const candidates = await collectCandidates(projectRoot);
216
+ const ignored = await gitIgnoredPaths(projectRoot, candidates);
217
+ const canonicalPaths = new Map<string, string>();
218
+ for (const relativePath of candidates) {
219
+ if (!ignored.has(evidenceKey(relativePath))) canonicalPaths.set(evidenceKey(relativePath), relativePath);
220
+ }
221
+
222
+ let snapshot = [
223
+ "# KillerOS repository snapshot",
224
+ "Root AGENTS.md is protected policy and is intentionally not part of this untrusted evidence.",
225
+ "",
226
+ "## Project files",
227
+ [...canonicalPaths.values()].join("\n"),
228
+ ].join("\n");
229
+ snapshot = appendWithinLimit("", snapshot, SNAPSHOT_LIMIT);
230
+ const automatic = new Set<string>(ROOT_EVIDENCE);
231
+ for (const relativePath of canonicalPaths.values()) {
232
+ const name = path.posix.basename(relativePath);
233
+ if (NESTED_EVIDENCE.has(name) || /^\.github\/workflows\/[^/]+\.ya?ml$/iu.test(relativePath)) automatic.add(relativePath);
234
+ }
235
+ for (const requested of automatic) {
236
+ const relativePath = canonicalPaths.get(evidenceKey(requested));
237
+ if (!relativePath || Buffer.byteLength(snapshot, "utf8") >= SNAPSHOT_LIMIT) continue;
238
+ try {
239
+ const result = await validateAndRead(projectRoot, path.join(projectRoot, relativePath), AUTOMATIC_FILE_LIMIT);
240
+ const suffix = result.truncated ? "\n[truncated by /init]" : "";
241
+ snapshot = appendWithinLimit(snapshot, `\n\n## ${relativePath}\n${result.content}${suffix}`, SNAPSHOT_LIMIT);
242
+ } catch {
243
+ // A mapped file may become unsafe or disappear before snapshot creation.
244
+ }
245
+ }
246
+ return { index: { projectRoot, canonicalPaths, snapshot } };
247
+ }
248
+
249
+ export async function readInitEvidence(index: InitEvidenceIndex, requestedPath: string): Promise<string> {
250
+ const normalized = normalizeRequestedPath(requestedPath);
251
+ const relativePath = index.canonicalPaths.get(evidenceKey(normalized));
252
+ if (!relativePath) throw new Error(`${requestedPath} is not available to /init`);
253
+ const result = await validateAndRead(index.projectRoot, path.join(index.projectRoot, relativePath), READ_LIMIT);
254
+ return result.truncated ? `${result.content}\n[truncated by /init at ${READ_LIMIT} bytes]` : result.content;
255
+ }
256
+
257
+ export async function readGeneratedInitTarget(projectRoot: string, targetPath: string): Promise<string> {
258
+ const result = await validateAndRead(projectRoot, targetPath, READ_LIMIT);
259
+ return result.truncated ? `${result.content}\n[truncated by /init at ${READ_LIMIT} bytes]` : result.content;
260
+ }
261
+
262
+ export function listInitEvidence(index: InitEvidenceIndex, requestedPath = "."): string[] {
263
+ const prefix = requestedPath === "." ? "" : normalizeRequestedPath(requestedPath).replace(/\/$/u, "");
264
+ const prefixWithSlash = prefix ? `${prefix}/` : "";
265
+ const children = new Set<string>();
266
+ let found = !prefix;
267
+ for (const relativePath of index.canonicalPaths.values()) {
268
+ if (!relativePath.startsWith(prefixWithSlash)) continue;
269
+ const remainder = relativePath.slice(prefixWithSlash.length);
270
+ if (!remainder) continue;
271
+ found = true;
272
+ children.add(remainder.split("/")[0]!);
273
+ }
274
+ if (!found) throw new Error(`${requestedPath} is not available to /init`);
275
+ return [...children].sort((left, right) => left.localeCompare(right)).slice(0, PATH_LIMIT);
276
+ }