killeros 2.0.4 → 2.0.6

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";
@@ -44,6 +45,10 @@ function loadKillerosHooks(ctx: ExtensionContext): KillerosHookConfig {
44
45
  const candidates = parsed.hooks?.[event];
45
46
  if (!Array.isArray(candidates)) continue;
46
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
+ }
47
52
  const valid = hook
48
53
  && typeof hook.command === "string"
49
54
  && hook.command.trim().length > 0
@@ -80,9 +85,19 @@ function matchesHook(hook: KillerosHook, value: string): boolean {
80
85
  }
81
86
  }
82
87
 
83
- function appendBounded(current: string, chunk: Buffer | string): string {
84
- if (current.length >= HOOK_OUTPUT_LIMIT) return current;
85
- 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);
86
101
  }
87
102
 
88
103
  function terminateHookProcess(child: ReturnType<typeof spawn>, force: boolean): void {
@@ -130,8 +145,8 @@ export function executeHook(
130
145
  stdio: ["ignore", "pipe", "pipe"],
131
146
  windowsHide: true,
132
147
  });
133
- let stdout = "";
134
- let stderr = "";
148
+ const stdout: HookOutputBuffer = { bytes: 0, decoder: new StringDecoder("utf8"), text: "" };
149
+ const stderr: HookOutputBuffer = { bytes: 0, decoder: new StringDecoder("utf8"), text: "" };
135
150
  let completed = false;
136
151
  let termination: "timeout" | "cancelled" | undefined;
137
152
  let timer: NodeJS.Timeout | undefined;
@@ -146,8 +161,8 @@ export function executeHook(
146
161
  signal?.removeEventListener("abort", abort);
147
162
  resolve({
148
163
  code,
149
- stdout,
150
- stderr,
164
+ stdout: stdout.text,
165
+ stderr: stderr.text,
151
166
  timedOut: termination === "timeout",
152
167
  cancelled: termination === "cancelled",
153
168
  exitUnconfirmed,
@@ -167,10 +182,10 @@ export function executeHook(
167
182
  const abort = (): void => beginTermination("cancelled");
168
183
  signal?.addEventListener("abort", abort, { once: true });
169
184
  if (signal?.aborted) beginTermination("cancelled");
170
- child.stdout.on("data", (chunk) => { stdout = appendBounded(stdout, chunk); });
171
- child.stderr.on("data", (chunk) => { stderr = appendBounded(stderr, chunk); });
185
+ child.stdout.on("data", (chunk) => appendBounded(stdout, chunk));
186
+ child.stderr.on("data", (chunk) => appendBounded(stderr, chunk));
172
187
  child.on("error", (error) => {
173
- stderr = appendBounded(stderr, error.message);
188
+ appendBounded(stderr, error.message);
174
189
  finish(termination ? terminationCode() : 1);
175
190
  });
176
191
  child.once("close", (code) => finish(termination ? terminationCode() : code ?? 1));
@@ -1,6 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { promises as fs } from "node:fs";
3
3
  import path from "node:path";
4
+ import { StringDecoder } from "node:string_decoder";
4
5
 
5
6
  export const INIT_READ_TOOL = "killeros_init_read";
6
7
  export const INIT_LIST_TOOL = "killeros_init_list";
@@ -91,7 +92,7 @@ async function collectCandidates(projectRoot: string): Promise<string[]> {
91
92
 
92
93
  async function gitIgnoredPaths(projectRoot: string, candidates: readonly string[]): Promise<ReadonlySet<string>> {
93
94
  if (!candidates.length) return new Set();
94
- return new Promise((resolve) => {
95
+ return new Promise((resolve, reject) => {
95
96
  let settled = false;
96
97
  let stdout = Buffer.alloc(0);
97
98
  const child = spawn("git", ["-C", projectRoot, "check-ignore", "--stdin", "-z"], {
@@ -99,7 +100,13 @@ async function gitIgnoredPaths(projectRoot: string, candidates: readonly string[
99
100
  stdio: ["pipe", "pipe", "ignore"],
100
101
  windowsHide: true,
101
102
  });
102
- const finish = (value: ReadonlySet<string>): void => {
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 => {
103
110
  if (settled) return;
104
111
  settled = true;
105
112
  clearTimeout(timer);
@@ -107,30 +114,59 @@ async function gitIgnoredPaths(projectRoot: string, candidates: readonly string[
107
114
  };
108
115
  const timer = setTimeout(() => {
109
116
  child.kill("SIGKILL");
110
- finish(new Set());
117
+ fail();
111
118
  }, 2_000);
112
119
  child.stdout.on("data", (chunk: Buffer) => {
113
- if (stdout.length <= 256 * 1024) stdout = Buffer.concat([stdout, chunk]);
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();
114
131
  });
115
- child.once("error", () => finish(new Set()));
116
132
  child.once("close", (code) => {
117
- if (code === 1) return finish(new Set());
118
- if (code !== 0 || stdout.length > 256 * 1024 || stdout.at(-1) !== 0) return finish(new Set());
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
+ }
119
149
  const candidateSet = new Set(candidates.map(evidenceKey));
120
- const values = stdout.subarray(0, -1).toString("utf8").split("\0");
121
- if (values.some((value) => !value || !candidateSet.has(evidenceKey(value)))) return finish(new Set());
122
- finish(new Set(values.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);
123
156
  });
124
- child.stdin.on("error", () => {});
125
157
  child.stdin.end(`${candidates.join("\0")}\0`);
126
158
  });
127
159
  }
128
160
 
161
+ function decodeCompleteUtf8(bytes: Buffer): string {
162
+ return new StringDecoder("utf8").write(bytes);
163
+ }
164
+
129
165
  function appendWithinLimit(current: string, section: string, limit: number): string {
130
166
  const remaining = limit - Buffer.byteLength(current, "utf8");
131
167
  if (remaining <= 0) return current;
132
168
  const bytes = Buffer.from(section, "utf8");
133
- return current + bytes.subarray(0, remaining).toString("utf8");
169
+ return current + decodeCompleteUtf8(bytes.subarray(0, remaining));
134
170
  }
135
171
 
136
172
  async function validateAndRead(projectRoot: string, absolutePath: string, limit: number): Promise<{ content: string; truncated: boolean }> {
@@ -157,7 +193,7 @@ async function validateAndRead(projectRoot: string, absolutePath: string, limit:
157
193
  const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
158
194
  const data = buffer.subarray(0, Math.min(bytesRead, limit));
159
195
  if (data.includes(0)) throw new Error("/init rejects binary files");
160
- return { content: data.toString("utf8"), truncated: bytesRead > limit };
196
+ return { content: decodeCompleteUtf8(data), truncated: bytesRead > limit };
161
197
  } finally {
162
198
  await handle.close();
163
199
  }
@@ -65,7 +65,7 @@ export function formatNotificationTitle(
65
65
  sessionName: string | undefined,
66
66
  enabled: boolean,
67
67
  ): string {
68
- const directory = basename(cwd);
68
+ const directory = basename(cwd) || cwd;
69
69
  const base = sessionName ? `π - ${sessionName} - ${directory}` : `π - ${directory}`;
70
70
  return enabled ? `${base} ${COMPLETION_BELL_GLYPH}` : base;
71
71
  }