killeros 1.4.9 → 1.5.0
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 +16 -0
- package/Killeros.ts +25 -2807
- package/README.md +18 -3
- package/killeros/commands.ts +164 -0
- package/killeros/concise.ts +23 -0
- package/killeros/display.ts +39 -0
- package/killeros/errors.ts +6 -0
- package/killeros/footer.ts +209 -0
- package/killeros/goals.ts +720 -0
- package/killeros/hooks.ts +219 -0
- package/killeros/init.ts +470 -0
- package/killeros/personal-instructions.ts +66 -0
- package/killeros/question.ts +468 -0
- package/killeros/runtime.ts +61 -0
- package/killeros/shell-ui.ts +270 -0
- package/killeros/subagent-lifecycle.ts +532 -0
- package/killeros/subagent-process.ts +601 -0
- package/killeros/subagent-ui.ts +227 -0
- package/killeros/subagents.ts +1917 -0
- package/killeros/variants.ts +136 -0
- package/package.json +2 -1
- package/subagent-lifecycle.ts +1 -532
- package/subagent-process.ts +1 -601
- package/subagent-ui.ts +1 -227
- package/subagents.ts +1 -1681
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { CONFIG_DIR_NAME, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { reportError } from "./errors.ts";
|
|
6
|
+
import { MAX_NODE_TIMER_MS } from "./subagent-process.ts";
|
|
7
|
+
|
|
8
|
+
type KillerosHookEvent = "tool_call" | "tool_result" | "agent_settled";
|
|
9
|
+
|
|
10
|
+
interface KillerosHook {
|
|
11
|
+
matcher?: string;
|
|
12
|
+
command: string;
|
|
13
|
+
timeoutMs?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface KillerosHookConfig {
|
|
17
|
+
hooks?: Partial<Record<KillerosHookEvent, KillerosHook[]>>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface HookExecutionResult {
|
|
21
|
+
code: number;
|
|
22
|
+
stdout: string;
|
|
23
|
+
stderr: string;
|
|
24
|
+
timedOut: boolean;
|
|
25
|
+
exitUnconfirmed: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const HOOK_EVENTS: readonly KillerosHookEvent[] = ["tool_call", "tool_result", "agent_settled"];
|
|
29
|
+
const HOOK_OUTPUT_LIMIT = 16 * 1024;
|
|
30
|
+
|
|
31
|
+
function loadKillerosHooks(ctx: ExtensionContext): KillerosHookConfig {
|
|
32
|
+
const configPath = path.join(ctx.cwd, CONFIG_DIR_NAME, "killeros-hooks.json");
|
|
33
|
+
if (!existsSync(configPath)) return {};
|
|
34
|
+
if (!ctx.isProjectTrusted()) {
|
|
35
|
+
ctx.ui.notify(`Ignored untrusted project hooks in ${configPath}`, "warning");
|
|
36
|
+
return {};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const parsed = JSON.parse(readFileSync(configPath, "utf8")) as KillerosHookConfig;
|
|
41
|
+
const hooks: KillerosHookConfig["hooks"] = {};
|
|
42
|
+
for (const event of HOOK_EVENTS) {
|
|
43
|
+
const candidates = parsed.hooks?.[event];
|
|
44
|
+
if (!Array.isArray(candidates)) continue;
|
|
45
|
+
hooks[event] = candidates.filter((hook, index) => {
|
|
46
|
+
const valid = hook
|
|
47
|
+
&& typeof hook.command === "string"
|
|
48
|
+
&& hook.command.trim().length > 0
|
|
49
|
+
&& (hook.matcher === undefined || typeof hook.matcher === "string")
|
|
50
|
+
&& (hook.timeoutMs === undefined || Number.isSafeInteger(hook.timeoutMs) && hook.timeoutMs > 0 && hook.timeoutMs <= MAX_NODE_TIMER_MS);
|
|
51
|
+
if (!valid) {
|
|
52
|
+
ctx.ui.notify(`Ignored invalid ${event} hook ${index + 1} in ${configPath}`, "warning");
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
if (hook.matcher && hook.matcher !== "*") {
|
|
56
|
+
try {
|
|
57
|
+
new RegExp(hook.matcher, "u");
|
|
58
|
+
} catch {
|
|
59
|
+
ctx.ui.notify(`Ignored ${event} hook ${index + 1}: invalid matcher ${JSON.stringify(hook.matcher)}`, "warning");
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return true;
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
return { hooks };
|
|
67
|
+
} catch (error) {
|
|
68
|
+
reportError(ctx, `Invalid ${CONFIG_DIR_NAME}/killeros-hooks.json`, error);
|
|
69
|
+
return {};
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function matchesHook(hook: KillerosHook, value: string): boolean {
|
|
74
|
+
if (!hook.matcher || hook.matcher === "*") return true;
|
|
75
|
+
try {
|
|
76
|
+
return new RegExp(hook.matcher, "u").test(value);
|
|
77
|
+
} catch {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
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);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function terminateHookProcess(child: ReturnType<typeof spawn>, force: boolean): void {
|
|
88
|
+
if (process.platform === "win32" && force && child.pid) {
|
|
89
|
+
const killer = spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], {
|
|
90
|
+
shell: false,
|
|
91
|
+
stdio: "ignore",
|
|
92
|
+
windowsHide: true,
|
|
93
|
+
});
|
|
94
|
+
killer.unref();
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (process.platform !== "win32" && child.pid) {
|
|
98
|
+
try {
|
|
99
|
+
process.kill(-child.pid, force ? "SIGKILL" : "SIGTERM");
|
|
100
|
+
return;
|
|
101
|
+
} catch {
|
|
102
|
+
// Fall back to the shell itself when a custom child has no process group.
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
child.kill(force ? "SIGKILL" : "SIGTERM");
|
|
107
|
+
} catch {
|
|
108
|
+
// The hook may have already exited.
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function executeHook(command: string, cwd: string, environment: Record<string, string>, timeoutMs = 30_000, spawnProcess: typeof spawn = spawn): Promise<HookExecutionResult> {
|
|
113
|
+
return new Promise((resolve) => {
|
|
114
|
+
const child = spawnProcess(command, {
|
|
115
|
+
cwd,
|
|
116
|
+
env: { ...process.env, ...environment },
|
|
117
|
+
detached: process.platform !== "win32",
|
|
118
|
+
shell: true,
|
|
119
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
120
|
+
windowsHide: true,
|
|
121
|
+
});
|
|
122
|
+
let stdout = "";
|
|
123
|
+
let stderr = "";
|
|
124
|
+
let completed = false;
|
|
125
|
+
let timedOut = false;
|
|
126
|
+
let exitUnconfirmed = false;
|
|
127
|
+
let timer: NodeJS.Timeout | undefined;
|
|
128
|
+
let forceTimer: NodeJS.Timeout | undefined;
|
|
129
|
+
let settleTimer: NodeJS.Timeout | undefined;
|
|
130
|
+
const finish = (code: number, unconfirmed = false): void => {
|
|
131
|
+
if (completed) return;
|
|
132
|
+
completed = true;
|
|
133
|
+
exitUnconfirmed = unconfirmed;
|
|
134
|
+
if (timer) clearTimeout(timer);
|
|
135
|
+
if (forceTimer) clearTimeout(forceTimer);
|
|
136
|
+
if (settleTimer) clearTimeout(settleTimer);
|
|
137
|
+
resolve({ code, stdout, stderr, timedOut, exitUnconfirmed });
|
|
138
|
+
};
|
|
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;
|
|
148
|
+
terminateHookProcess(child, false);
|
|
149
|
+
forceTimer = setTimeout(() => {
|
|
150
|
+
if (completed) return;
|
|
151
|
+
terminateHookProcess(child, true);
|
|
152
|
+
settleTimer = setTimeout(() => finish(124, true), 1_000);
|
|
153
|
+
}, 1_000);
|
|
154
|
+
}, Math.max(1_000, Math.min(timeoutMs, 300_000)));
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function hookEnvironment(event: KillerosHookEvent, toolName = "", payload: unknown = {}): Record<string, string> {
|
|
159
|
+
return {
|
|
160
|
+
KILLEROS_EVENT: event,
|
|
161
|
+
KILLEROS_TOOL: toolName,
|
|
162
|
+
KILLEROS_PAYLOAD: JSON.stringify(payload).slice(0, 8_000),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function hookFailureMessage(hook: KillerosHook, result: HookExecutionResult): string {
|
|
167
|
+
const detail = result.stderr.trim() || result.stdout.trim() || `exit code ${result.code}`;
|
|
168
|
+
return `Hook failed${result.timedOut ? " (timed out)" : ""}${result.exitUnconfirmed ? " (process exit unconfirmed)" : ""}: ${hook.command}\n${detail}`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function registerLifecycleHooks(pi: ExtensionAPI): void {
|
|
172
|
+
let config: KillerosHookConfig = {};
|
|
173
|
+
pi.on("session_start", (_event, ctx) => { config = loadKillerosHooks(ctx); });
|
|
174
|
+
|
|
175
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
176
|
+
for (const hook of config.hooks?.tool_call ?? []) {
|
|
177
|
+
if (!matchesHook(hook, event.toolName)) continue;
|
|
178
|
+
const result = await executeHook(
|
|
179
|
+
hook.command,
|
|
180
|
+
ctx.cwd,
|
|
181
|
+
hookEnvironment("tool_call", event.toolName, event.input),
|
|
182
|
+
hook.timeoutMs,
|
|
183
|
+
);
|
|
184
|
+
if (result.code !== 0) {
|
|
185
|
+
const reason = hookFailureMessage(hook, result);
|
|
186
|
+
ctx.ui.notify(reason, "error");
|
|
187
|
+
return { block: true, reason };
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
pi.on("tool_result", async (event, ctx) => {
|
|
193
|
+
for (const hook of config.hooks?.tool_result ?? []) {
|
|
194
|
+
if (!matchesHook(hook, event.toolName)) continue;
|
|
195
|
+
const result = await executeHook(
|
|
196
|
+
hook.command,
|
|
197
|
+
ctx.cwd,
|
|
198
|
+
hookEnvironment("tool_result", event.toolName, {
|
|
199
|
+
input: event.input,
|
|
200
|
+
isError: event.isError,
|
|
201
|
+
}),
|
|
202
|
+
hook.timeoutMs,
|
|
203
|
+
);
|
|
204
|
+
if (result.code !== 0) ctx.ui.notify(hookFailureMessage(hook, result), "error");
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
pi.on("agent_settled", async (_event, ctx) => {
|
|
209
|
+
for (const hook of config.hooks?.agent_settled ?? []) {
|
|
210
|
+
const result = await executeHook(
|
|
211
|
+
hook.command,
|
|
212
|
+
ctx.cwd,
|
|
213
|
+
hookEnvironment("agent_settled"),
|
|
214
|
+
hook.timeoutMs,
|
|
215
|
+
);
|
|
216
|
+
if (result.code !== 0) ctx.ui.notify(hookFailureMessage(hook, result), "error");
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
}
|
package/killeros/init.ts
ADDED
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
import { promises as fs, closeSync, existsSync, openSync, readSync } from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { Type } from "typebox";
|
|
7
|
+
import { reportError } from "./errors.ts";
|
|
8
|
+
import { resetInitRuntime, type GoalRuntime, type InitRuntime } from "./runtime.ts";
|
|
9
|
+
|
|
10
|
+
const INIT_WRITE_TOOL = "killeros_init_write";
|
|
11
|
+
const INIT_SCOPED_TOOLS = ["read", "ls", INIT_WRITE_TOOL] as const;
|
|
12
|
+
const INIT_GENERATED_CONTENT_LIMIT = 128 * 1024;
|
|
13
|
+
|
|
14
|
+
const INIT_SURVEY_OUTPUT_LIMIT = 40 * 1024;
|
|
15
|
+
const INIT_SURVEY_FILE_LIMIT = 8 * 1024;
|
|
16
|
+
const INIT_SURVEY_PATH_LIMIT = 400;
|
|
17
|
+
const INIT_SURVEY_DIRECTORY_LIMIT = 120;
|
|
18
|
+
const INIT_SURVEY_DEPTH_LIMIT = 4;
|
|
19
|
+
const INIT_SURVEY_EXCLUDED_DIRS = new Set([
|
|
20
|
+
".agents", ".claude", ".git", ".next", ".pi", ".pytest_cache", ".turbo", ".venv", "__pycache__", "archive", "build", "coverage", "data", "dist", "logs", "node_modules", "target", "test-results", "vendor",
|
|
21
|
+
]);
|
|
22
|
+
const INIT_SURVEY_EXCLUDED_FILES = new Set([
|
|
23
|
+
".cursorrules", "AGENTS.md", "AGENTS.local.md", "CLAUDE.md", "CLAUDE.local.md", "GEMINI.md", "MEMORY.md", "SKILL.md", "copilot-instructions.md",
|
|
24
|
+
]);
|
|
25
|
+
const INIT_SURVEY_ROOT_FILES = [
|
|
26
|
+
"README.md",
|
|
27
|
+
"README.rst",
|
|
28
|
+
"README.txt",
|
|
29
|
+
"package.json",
|
|
30
|
+
"pyproject.toml",
|
|
31
|
+
"requirements.txt",
|
|
32
|
+
"Cargo.toml",
|
|
33
|
+
"go.mod",
|
|
34
|
+
"Makefile",
|
|
35
|
+
"Dockerfile",
|
|
36
|
+
"compose.yaml",
|
|
37
|
+
"compose.yml",
|
|
38
|
+
"config.yaml",
|
|
39
|
+
"config.yml",
|
|
40
|
+
"tsconfig.json",
|
|
41
|
+
"vite.config.ts",
|
|
42
|
+
"vite.config.js",
|
|
43
|
+
"eslint.config.js",
|
|
44
|
+
"eslint.config.mjs",
|
|
45
|
+
] as const;
|
|
46
|
+
const INIT_SURVEY_NESTED_FILES = new Set([
|
|
47
|
+
"package.json", "pyproject.toml", "requirements.txt", "Cargo.toml", "go.mod",
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
async function collectInitProjectFiles(cwd: string): Promise<string[]> {
|
|
51
|
+
const files: string[] = [];
|
|
52
|
+
const queue: Array<{ relativePath: string; depth: number }> = [{ relativePath: "", depth: 0 }];
|
|
53
|
+
let directoriesRead = 0;
|
|
54
|
+
while (queue.length && files.length < INIT_SURVEY_PATH_LIMIT && directoriesRead < INIT_SURVEY_DIRECTORY_LIMIT) {
|
|
55
|
+
const current = queue.shift()!;
|
|
56
|
+
directoriesRead += 1;
|
|
57
|
+
let entries;
|
|
58
|
+
try {
|
|
59
|
+
entries = await fs.readdir(path.join(cwd, current.relativePath), { withFileTypes: true });
|
|
60
|
+
} catch (error) {
|
|
61
|
+
if (!current.relativePath) throw error;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
entries.sort((left, right) => left.name === right.name ? 0 : left.name < right.name ? -1 : 1);
|
|
65
|
+
for (const entry of entries) {
|
|
66
|
+
if (files.length >= INIT_SURVEY_PATH_LIMIT) break;
|
|
67
|
+
const relativePath = path.join(current.relativePath, entry.name);
|
|
68
|
+
if (entry.isDirectory()) {
|
|
69
|
+
if (current.depth < INIT_SURVEY_DEPTH_LIMIT && !INIT_SURVEY_EXCLUDED_DIRS.has(entry.name)) {
|
|
70
|
+
queue.push({ relativePath, depth: current.depth + 1 });
|
|
71
|
+
}
|
|
72
|
+
} else if (entry.isFile() && !INIT_SURVEY_EXCLUDED_FILES.has(entry.name)) {
|
|
73
|
+
files.push(relativePath.replaceAll("\\", "/"));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return files;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function readFilePrefix(filePath: string, limit: number): Promise<string> {
|
|
81
|
+
const handle = await fs.open(filePath, "r");
|
|
82
|
+
try {
|
|
83
|
+
const buffer = Buffer.alloc(limit);
|
|
84
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
|
85
|
+
return buffer.toString("utf8", 0, bytesRead);
|
|
86
|
+
} finally {
|
|
87
|
+
await handle.close();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function runInitSurvey(
|
|
92
|
+
cwd: string,
|
|
93
|
+
): Promise<{ output: string; error?: string }> {
|
|
94
|
+
let projectFiles: string[];
|
|
95
|
+
try {
|
|
96
|
+
projectFiles = await collectInitProjectFiles(cwd);
|
|
97
|
+
} catch (error) {
|
|
98
|
+
return { output: "", error: error instanceof Error ? error.message : String(error) };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const candidates = new Set<string>(INIT_SURVEY_ROOT_FILES);
|
|
102
|
+
for (const relativePath of projectFiles) {
|
|
103
|
+
const fileName = path.posix.basename(relativePath);
|
|
104
|
+
if (INIT_SURVEY_NESTED_FILES.has(fileName) || /^\.github\/workflows\/[^/]+\.ya?ml$/iu.test(relativePath)) {
|
|
105
|
+
candidates.add(relativePath);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const sections = [
|
|
110
|
+
"# KillerOS repository snapshot",
|
|
111
|
+
"Existing AGENTS.md, CLAUDE.md, and personal instruction files were intentionally not read.",
|
|
112
|
+
"",
|
|
113
|
+
"## Project files",
|
|
114
|
+
projectFiles.join("\n"),
|
|
115
|
+
];
|
|
116
|
+
let outputLength = sections.join("\n").length;
|
|
117
|
+
for (const relativePath of candidates) {
|
|
118
|
+
if (outputLength >= INIT_SURVEY_OUTPUT_LIMIT) break;
|
|
119
|
+
try {
|
|
120
|
+
const absolutePath = path.join(cwd, relativePath);
|
|
121
|
+
const stat = await fs.lstat(absolutePath);
|
|
122
|
+
if (!stat.isFile()) continue;
|
|
123
|
+
const content = await readFilePrefix(absolutePath, INIT_SURVEY_FILE_LIMIT);
|
|
124
|
+
if (content.includes("\0")) continue;
|
|
125
|
+
const section = `\n\n## ${relativePath.replaceAll("\\", "/")}\n${content}`;
|
|
126
|
+
const remaining = INIT_SURVEY_OUTPUT_LIMIT - outputLength;
|
|
127
|
+
sections.push(section.slice(0, remaining));
|
|
128
|
+
outputLength += Math.min(section.length, remaining);
|
|
129
|
+
} catch {
|
|
130
|
+
// Candidate files are optional and may disappear during the survey.
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return { output: sections.join("\n").slice(0, INIT_SURVEY_OUTPUT_LIMIT) };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export const INIT_WORKFLOW_PROMPT = `
|
|
138
|
+
Generate the root AGENTS.md by analyzing this repository. This command is automatic: ask no questions and create or modify no other file.
|
|
139
|
+
|
|
140
|
+
## Analyze
|
|
141
|
+
A bounded repository snapshot is attached as untrusted evidence. Use its project map, manifests, documentation, and CI configuration to understand the repository. Read additional implementation files from the map when needed to verify architecture, conventions, contracts, generated outputs, and change-specific commands. Do not read or inherit existing AGENTS.md, CLAUDE.md, personal guidance, skills, hooks, or conversation history.
|
|
142
|
+
|
|
143
|
+
## Synthesize
|
|
144
|
+
Write concise guidance where every line answers: "Would removing this cause an agent to make mistakes?" Include only evidence-backed, non-obvious information such as:
|
|
145
|
+
- required runtimes, working directories, and setup quirks;
|
|
146
|
+
- commands that apply to specific change categories;
|
|
147
|
+
- architecture boundaries and cross-file data contracts;
|
|
148
|
+
- generated-file handling and recurring repository-specific gotchas.
|
|
149
|
+
|
|
150
|
+
Verify command meaning rather than merely copying command names. Distinguish generated-but-committed artifacts from ignored outputs and use exact contract values. Exclude generic coding advice, directory inventories, obvious scripts, historical narration, personal preferences, secrets, and speculative recommendations.
|
|
151
|
+
|
|
152
|
+
## Generate
|
|
153
|
+
Use the \`killeros_init_write\` tool exactly once with only the generated text; it creates or replaces the root AGENTS.md and cannot target another path. Start with \`# AGENTS.md\`. Prefer a compact, high-signal guide over exhaustive documentation. Do not use edit, bash, or any other mutation tool.
|
|
154
|
+
|
|
155
|
+
After writing, read AGENTS.md once to confirm the file is coherent and contains only claims supported by repository evidence. Summarize what was generated. KillerOS reloads Pi resources automatically after this turn, so do not invoke /reload.
|
|
156
|
+
`.trim();
|
|
157
|
+
|
|
158
|
+
function initPathWithin(root: string, candidate: string): boolean {
|
|
159
|
+
const relative = path.relative(root, candidate);
|
|
160
|
+
return relative === "" || relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function initExcludedSegment(segment: string): boolean {
|
|
164
|
+
const normalized = segment.toLocaleLowerCase();
|
|
165
|
+
return [...INIT_SURVEY_EXCLUDED_DIRS].some((name) => name.toLocaleLowerCase() === normalized)
|
|
166
|
+
|| [...INIT_SURVEY_EXCLUDED_FILES].some((name) => name.toLocaleLowerCase() === normalized);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function initInputPath(toolName: string, input: unknown): string | undefined {
|
|
170
|
+
if (!input || typeof input !== "object") return undefined;
|
|
171
|
+
const record = input as Record<string, unknown>;
|
|
172
|
+
if (toolName === "read" && typeof record.file_path === "string") return record.file_path;
|
|
173
|
+
return typeof record.path === "string" ? record.path : toolName === "ls" || toolName === "find" || toolName === "grep" ? "." : undefined;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function normalizeInitReadPath(rawPath: string): string {
|
|
177
|
+
// Mirror Pi's built-in read/ls path normalization (stripAtPrefix, unicode spaces,
|
|
178
|
+
// tilde expansion, file URLs) so /init validates the exact path the scoped tools
|
|
179
|
+
// will resolve rather than the raw user text.
|
|
180
|
+
let normalized = rawPath.replace(/[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g, " ");
|
|
181
|
+
if (normalized.startsWith("@")) normalized = normalized.slice(1);
|
|
182
|
+
if (normalized === "~") normalized = os.homedir();
|
|
183
|
+
else if (normalized.startsWith("~/") || (process.platform === "win32" && normalized.startsWith("~\\"))) {
|
|
184
|
+
normalized = path.join(os.homedir(), normalized.slice(2));
|
|
185
|
+
}
|
|
186
|
+
if (/^file:\/\//u.test(normalized)) {
|
|
187
|
+
try {
|
|
188
|
+
normalized = fileURLToPath(normalized);
|
|
189
|
+
} catch {
|
|
190
|
+
return "";
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return normalized;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function resolveInitToolPath(input: unknown, cwd: string): string | undefined {
|
|
197
|
+
const rawPath = initInputPath("read", input);
|
|
198
|
+
if (!rawPath) return undefined;
|
|
199
|
+
const normalizedPath = normalizeInitReadPath(rawPath);
|
|
200
|
+
return normalizedPath ? path.resolve(cwd, normalizedPath) : undefined;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function initScopedPathError(
|
|
204
|
+
toolName: string,
|
|
205
|
+
input: unknown,
|
|
206
|
+
projectRoot: string,
|
|
207
|
+
targetPath: string,
|
|
208
|
+
writeSucceeded: boolean,
|
|
209
|
+
): Promise<string | undefined> {
|
|
210
|
+
const rawPath = initInputPath(toolName, input);
|
|
211
|
+
if (!rawPath) return `/init ${toolName} requires a path under the project root`;
|
|
212
|
+
const normalizedPath = normalizeInitReadPath(rawPath);
|
|
213
|
+
if (!normalizedPath || normalizedPath.split(/[\\/]/u).includes("..")) return "/init rejects parent-directory read paths";
|
|
214
|
+
const candidate = toolName === "read"
|
|
215
|
+
? resolveInitToolPath(input, projectRoot)
|
|
216
|
+
: path.resolve(projectRoot, normalizedPath);
|
|
217
|
+
if (!candidate || !initPathWithin(projectRoot, candidate)) return "/init reads must remain under the resolved project root";
|
|
218
|
+
const relativeSegments = path.relative(projectRoot, candidate).split(path.sep).filter(Boolean);
|
|
219
|
+
const isGeneratedTarget = writeSucceeded && candidate.toLocaleLowerCase() === targetPath.toLocaleLowerCase();
|
|
220
|
+
for (let index = 0; index < relativeSegments.length; index += 1) {
|
|
221
|
+
const segment = relativeSegments[index]!;
|
|
222
|
+
if (initExcludedSegment(segment) && !(isGeneratedTarget && index === relativeSegments.length - 1 && segment.toLocaleLowerCase() === "agents.md")) {
|
|
223
|
+
return "/init cannot read excluded guidance, skills, or dependency paths";
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
let current = projectRoot;
|
|
228
|
+
try {
|
|
229
|
+
for (const segment of relativeSegments) {
|
|
230
|
+
current = path.join(current, segment);
|
|
231
|
+
const stat = await fs.lstat(current);
|
|
232
|
+
if (stat.isSymbolicLink()) return "/init rejects symbolic-link and junction read paths";
|
|
233
|
+
}
|
|
234
|
+
const realPath = await fs.realpath(candidate);
|
|
235
|
+
if (!initPathWithin(projectRoot, realPath)) return "/init reads must remain under the resolved project root";
|
|
236
|
+
const stat = await fs.lstat(candidate);
|
|
237
|
+
if (stat.isSymbolicLink()) return "/init rejects symbolic-link and junction read paths";
|
|
238
|
+
if (stat.isFile() && stat.nlink > 1) return "/init rejects hard-linked read paths";
|
|
239
|
+
} catch (error) {
|
|
240
|
+
return `/init could not validate read path: ${error instanceof Error ? error.message : String(error)}`;
|
|
241
|
+
}
|
|
242
|
+
return undefined;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
interface InitTargetIdentity {
|
|
246
|
+
dev: number;
|
|
247
|
+
ino: number;
|
|
248
|
+
mode: number;
|
|
249
|
+
nlink: number;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function initTargetIdentity(targetPath: string): Promise<InitTargetIdentity | undefined> {
|
|
253
|
+
try {
|
|
254
|
+
const stat = await fs.lstat(targetPath);
|
|
255
|
+
return { dev: stat.dev, ino: stat.ino, mode: stat.mode, nlink: stat.nlink };
|
|
256
|
+
} catch (error) {
|
|
257
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
258
|
+
throw error;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function sameInitTargetIdentity(left: InitTargetIdentity | undefined, right: InitTargetIdentity | undefined): boolean {
|
|
263
|
+
if (!left || !right) return left === right;
|
|
264
|
+
return left.dev === right.dev && left.ino === right.ino && left.mode === right.mode && left.nlink === right.nlink;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function initTargetSafetyError(targetPath: string): Promise<string | undefined> {
|
|
268
|
+
try {
|
|
269
|
+
const stat = await fs.lstat(targetPath);
|
|
270
|
+
if (stat.isSymbolicLink() || !stat.isFile() || stat.nlink > 1) {
|
|
271
|
+
return "/init requires root AGENTS.md to be absent or a regular, non-linked file";
|
|
272
|
+
}
|
|
273
|
+
} catch (error) {
|
|
274
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
275
|
+
return `/init could not inspect root AGENTS.md: ${error instanceof Error ? error.message : String(error)}`;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return undefined;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export async function writeInitAgentsFile(
|
|
282
|
+
targetPath: string,
|
|
283
|
+
content: string,
|
|
284
|
+
renameFile: typeof fs.rename = fs.rename,
|
|
285
|
+
): Promise<void> {
|
|
286
|
+
const safetyError = await initTargetSafetyError(targetPath);
|
|
287
|
+
if (safetyError) throw new Error(safetyError);
|
|
288
|
+
const before = await initTargetIdentity(targetPath);
|
|
289
|
+
const tempDirectory = await fs.mkdtemp(path.join(path.dirname(targetPath), ".killeros-init-"));
|
|
290
|
+
const tempPath = path.join(tempDirectory, "AGENTS.md");
|
|
291
|
+
try {
|
|
292
|
+
const handle = await fs.open(tempPath, "wx", 0o600);
|
|
293
|
+
try {
|
|
294
|
+
await handle.writeFile(content, { encoding: "utf8" });
|
|
295
|
+
await handle.sync();
|
|
296
|
+
} finally {
|
|
297
|
+
await handle.close();
|
|
298
|
+
}
|
|
299
|
+
const after = await initTargetIdentity(targetPath);
|
|
300
|
+
if (!sameInitTargetIdentity(before, after)) throw new Error("/init target changed while AGENTS.md was being generated");
|
|
301
|
+
await renameFile(tempPath, targetPath);
|
|
302
|
+
} finally {
|
|
303
|
+
await fs.rm(tempDirectory, { recursive: true, force: true });
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function setInitTools(pi: ExtensionAPI, initState: InitRuntime, active: boolean): void {
|
|
308
|
+
const runtime = pi as ExtensionAPI & { getActiveTools?: () => string[]; setActiveTools?: (names: string[]) => void };
|
|
309
|
+
if (!runtime.getActiveTools || !runtime.setActiveTools) return;
|
|
310
|
+
if (active) {
|
|
311
|
+
initState.activeTools ??= runtime.getActiveTools().filter((name) => name !== INIT_WRITE_TOOL);
|
|
312
|
+
runtime.setActiveTools([...INIT_SCOPED_TOOLS]);
|
|
313
|
+
} else if (initState.activeTools) {
|
|
314
|
+
runtime.setActiveTools(initState.activeTools);
|
|
315
|
+
initState.activeTools = undefined;
|
|
316
|
+
} else {
|
|
317
|
+
runtime.setActiveTools(runtime.getActiveTools().filter((name) => name !== INIT_WRITE_TOOL));
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function freezeInitToolInput(event: { input: Record<string, unknown> }): void {
|
|
322
|
+
const safeInput = Object.freeze({ ...event.input });
|
|
323
|
+
Object.defineProperty(event, "input", {
|
|
324
|
+
configurable: false,
|
|
325
|
+
enumerable: true,
|
|
326
|
+
value: safeInput,
|
|
327
|
+
writable: false,
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export function registerInitCommand(pi: ExtensionAPI, initState: InitRuntime, goalRuntime: GoalRuntime): void {
|
|
332
|
+
pi.registerTool({
|
|
333
|
+
name: INIT_WRITE_TOOL,
|
|
334
|
+
label: "Init write",
|
|
335
|
+
description: "Write the generated root AGENTS.md during /init; the destination is fixed by KillerOS.",
|
|
336
|
+
promptSnippet: "Write the generated root AGENTS.md during /init",
|
|
337
|
+
parameters: Type.Object({ content: Type.String({ minLength: 1, maxLength: INIT_GENERATED_CONTENT_LIMIT }) }),
|
|
338
|
+
executionMode: "sequential",
|
|
339
|
+
async execute(_toolCallId, params) {
|
|
340
|
+
if (!initState.active || !initState.targetPath) throw new Error("killeros_init_write is available only during /init");
|
|
341
|
+
if (initState.writeAttempted) throw new Error("/init may write the root AGENTS.md exactly once and may not modify any other file");
|
|
342
|
+
if (Buffer.byteLength(params.content, "utf8") > INIT_GENERATED_CONTENT_LIMIT) throw new Error(`/init output exceeds ${INIT_GENERATED_CONTENT_LIMIT} bytes`);
|
|
343
|
+
initState.writeAttempted = true;
|
|
344
|
+
try {
|
|
345
|
+
await writeInitAgentsFile(initState.targetPath, params.content);
|
|
346
|
+
initState.writeSucceeded = true;
|
|
347
|
+
return {
|
|
348
|
+
content: [{ type: "text" as const, text: "Generated root AGENTS.md" }],
|
|
349
|
+
details: { path: initState.targetPath },
|
|
350
|
+
};
|
|
351
|
+
} catch (error) {
|
|
352
|
+
initState.writeAttempted = false;
|
|
353
|
+
throw error;
|
|
354
|
+
}
|
|
355
|
+
},
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
pi.on("session_start", () => setInitTools(pi, initState, false));
|
|
359
|
+
pi.on("session_shutdown", () => {
|
|
360
|
+
setInitTools(pi, initState, false);
|
|
361
|
+
resetInitRuntime(initState);
|
|
362
|
+
});
|
|
363
|
+
pi.on("before_agent_start", () => {
|
|
364
|
+
if (initState.active) setInitTools(pi, initState, true);
|
|
365
|
+
});
|
|
366
|
+
pi.on("tool_call", async (event) => {
|
|
367
|
+
if (!initState.active || !initState.projectRoot || !initState.targetPath) return;
|
|
368
|
+
if (event.toolName === INIT_WRITE_TOOL) {
|
|
369
|
+
if (initState.writeAttempted) return { block: true, reason: "/init may write AGENTS.md exactly once" };
|
|
370
|
+
freezeInitToolInput(event);
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
if (!INIT_SCOPED_TOOLS.includes(event.toolName as (typeof INIT_SCOPED_TOOLS)[number])) {
|
|
374
|
+
return { block: true, reason: "/init may write the root AGENTS.md exactly once and may not modify any other file" };
|
|
375
|
+
}
|
|
376
|
+
const pathError = await initScopedPathError(event.toolName, event.input, initState.projectRoot, initState.targetPath, initState.writeSucceeded);
|
|
377
|
+
if (pathError) return { block: true, reason: pathError };
|
|
378
|
+
freezeInitToolInput(event);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
pi.registerCommand("init", {
|
|
382
|
+
description: "Generate root AGENTS.md from repository evidence",
|
|
383
|
+
handler: async (args, ctx) => {
|
|
384
|
+
if (args.trim()) {
|
|
385
|
+
ctx.ui.notify("/init does not accept arguments", "error");
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
if (ctx.mode !== "tui") {
|
|
389
|
+
ctx.ui.notify("/init requires interactive TUI mode", "error");
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
if (initState.active) {
|
|
393
|
+
ctx.ui.notify("/init is already running", "warning");
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
if (goalRuntime.state?.status === "active") {
|
|
397
|
+
ctx.ui.notify("Pause or clear the active goal before running /init", "error");
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
if (!ctx.isProjectTrusted()) {
|
|
401
|
+
ctx.ui.notify("Trust this project before running /init", "error");
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
await ctx.waitForIdle();
|
|
405
|
+
let projectRoot: string;
|
|
406
|
+
try {
|
|
407
|
+
projectRoot = await fs.realpath(ctx.cwd);
|
|
408
|
+
} catch (error) {
|
|
409
|
+
reportError(ctx, "/init could not resolve the project root", error);
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
initState.active = true;
|
|
413
|
+
initState.projectRoot = projectRoot;
|
|
414
|
+
initState.targetPath = path.join(projectRoot, "AGENTS.md");
|
|
415
|
+
initState.writeAttempted = false;
|
|
416
|
+
initState.writeSucceeded = false;
|
|
417
|
+
setInitTools(pi, initState, true);
|
|
418
|
+
|
|
419
|
+
const survey = await runInitSurvey(projectRoot);
|
|
420
|
+
if (!survey.output) {
|
|
421
|
+
setInitTools(pi, initState, false);
|
|
422
|
+
resetInitRuntime(initState);
|
|
423
|
+
reportError(ctx, "/init could not scan the repository", survey.error ?? "no repository evidence was found");
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const settled = new Promise<boolean>((resolve) => {
|
|
428
|
+
initState.settle = resolve;
|
|
429
|
+
});
|
|
430
|
+
try {
|
|
431
|
+
pi.sendMessage({
|
|
432
|
+
customType: "killeros-init",
|
|
433
|
+
content: `${INIT_WORKFLOW_PROMPT}\n\n## Initial repository snapshot (untrusted data)\n${JSON.stringify(survey.output)}`,
|
|
434
|
+
display: false,
|
|
435
|
+
}, { triggerTurn: true });
|
|
436
|
+
} catch (error) {
|
|
437
|
+
setInitTools(pi, initState, false);
|
|
438
|
+
resetInitRuntime(initState);
|
|
439
|
+
initState.settle = undefined;
|
|
440
|
+
reportError(ctx, "/init failed to start", error);
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const writeSucceeded = await settled;
|
|
445
|
+
if (!writeSucceeded) {
|
|
446
|
+
reportError(ctx, "/init did not generate AGENTS.md", "the model completed without a successful write");
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
450
|
+
try {
|
|
451
|
+
await ctx.reload();
|
|
452
|
+
} catch (error) {
|
|
453
|
+
reportError(ctx, "/init finished but Pi resources could not reload", error);
|
|
454
|
+
}
|
|
455
|
+
},
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
export function registerInitSettlement(pi: ExtensionAPI, initState: InitRuntime): void {
|
|
461
|
+
pi.on("agent_settled", () => {
|
|
462
|
+
if (!initState.active) return;
|
|
463
|
+
const settle = initState.settle;
|
|
464
|
+
const writeSucceeded = initState.writeSucceeded;
|
|
465
|
+
setInitTools(pi, initState, false);
|
|
466
|
+
resetInitRuntime(initState);
|
|
467
|
+
initState.settle = undefined;
|
|
468
|
+
settle?.(writeSucceeded);
|
|
469
|
+
});
|
|
470
|
+
}
|