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/CHANGELOG.md +35 -0
- package/Killeros.ts +2 -0
- package/README.md +20 -12
- package/killeros/commands.ts +1 -2
- package/killeros/goals.ts +179 -51
- package/killeros/hooks.ts +25 -10
- package/killeros/init-evidence.ts +49 -13
- package/killeros/notifications.ts +1 -1
- package/killeros/question.ts +381 -182
- package/killeros/runtime.ts +7 -0
- package/killeros/shell-ui.ts +41 -13
- package/killeros/worked-for.ts +80 -0
- package/package.json +1 -1
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
134
|
-
|
|
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) =>
|
|
171
|
-
child.stderr.on("data", (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
|
-
|
|
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
|
|
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
|
-
|
|
117
|
+
fail();
|
|
111
118
|
}, 2_000);
|
|
112
119
|
child.stdout.on("data", (chunk: Buffer) => {
|
|
113
|
-
if (stdout.length
|
|
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)
|
|
118
|
-
|
|
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
|
|
121
|
-
if (values.some((value) => !value || !candidateSet.has(evidenceKey(value)))
|
|
122
|
-
|
|
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)
|
|
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
|
|
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
|
}
|