killeros 2.0.3 → 2.0.4
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 +17 -0
- package/Killeros.ts +4 -2
- package/README.md +9 -7
- package/killeros/commands.ts +2 -8
- package/killeros/concise.ts +12 -8
- package/killeros/goals.ts +0 -2
- package/killeros/hooks.ts +49 -17
- package/killeros/init-evidence.ts +240 -0
- package/killeros/init-target.ts +289 -0
- package/killeros/init.ts +139 -356
- package/killeros/runtime.ts +18 -6
- package/killeros/shell-ui.ts +17 -141
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,23 @@ All notable changes to KillerOS are documented here.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [2.0.4] - 2026-08-10
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- Rebuilt `/init` around a packaged four-section guideline-synthesis workflow that preserves compatible root policy without requiring an external skill.
|
|
12
|
+
- Removed live blue slash-command coloring while retaining slash autocomplete, the framed multiline editor, and Shift+Enter.
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- Prevented `/init` from reading Git-ignored files, known secrets, private keys, linked or non-regular files, other guidance, dependencies, and files outside its frozen evidence map.
|
|
17
|
+
- Protected root `AGENTS.md` from concurrent replacement and reported incompatible policy conflicts without writing.
|
|
18
|
+
- Made `/clear` confirm before aborting active work, wait for settlement, and then start the new session.
|
|
19
|
+
- Preserved explicit Responses API verbosity and reasoning-summary settings while applying concise defaults only to absent fields.
|
|
20
|
+
- Cancelled lifecycle hook process trees with bounded cleanup when Pi aborts the parent request.
|
|
21
|
+
- Kept `/init` middleware from freezing shared tool input and preserved custom editors installed by other extensions.
|
|
22
|
+
- Removed private Pi editor-state access in favor of public rendering contracts.
|
|
23
|
+
|
|
7
24
|
## [2.0.3] - 2026-08-09
|
|
8
25
|
|
|
9
26
|
### Added
|
package/Killeros.ts
CHANGED
|
@@ -15,10 +15,12 @@ import { createGoalRuntime, createInitRuntime } from "./killeros/runtime.ts";
|
|
|
15
15
|
import { registerShellUi } from "./killeros/shell-ui.ts";
|
|
16
16
|
import { registerVariants } from "./killeros/variants.ts";
|
|
17
17
|
|
|
18
|
-
export { CONCISE_SYSTEM_PROMPT, isConcisedEnabled } from "./killeros/concise.ts";
|
|
18
|
+
export { CONCISE_SYSTEM_PROMPT, isConciseEnabled, isConcisedEnabled } from "./killeros/concise.ts";
|
|
19
19
|
export { contextPercentRemaining, formatCost, formatContextProgress } from "./killeros/footer.ts";
|
|
20
20
|
export { executeHook } from "./killeros/hooks.ts";
|
|
21
|
-
export { INIT_WORKFLOW_PROMPT
|
|
21
|
+
export { INIT_WORKFLOW_PROMPT } from "./killeros/init.ts";
|
|
22
|
+
export { buildInitEvidence, listInitEvidence, readInitEvidence } from "./killeros/init-evidence.ts";
|
|
23
|
+
export { captureInitTargetBaseline, installInitAgentsFile, validateGeneratedGuidance, writeInitAgentsFile } from "./killeros/init-target.ts";
|
|
22
24
|
|
|
23
25
|
export interface KillerosOptions {
|
|
24
26
|
completionNotifications?: CompletionNotificationDependencies;
|
package/README.md
CHANGED
|
@@ -31,7 +31,7 @@ pi install git:github.com/KyrosHendrix/pi-KillerOS
|
|
|
31
31
|
Pin an install to a release:
|
|
32
32
|
|
|
33
33
|
```bash
|
|
34
|
-
pi install git:github.com/KyrosHendrix/pi-KillerOS@v2.0.
|
|
34
|
+
pi install git:github.com/KyrosHendrix/pi-KillerOS@v2.0.4
|
|
35
35
|
```
|
|
36
36
|
|
|
37
37
|
Add `-l` to either command for a project-only install. Restart Pi after installing.
|
|
@@ -41,17 +41,17 @@ Add `-l` to either command for a project-only install. Restart Pi after installi
|
|
|
41
41
|
- 52-column Compact startup card with inline version, polished model/provider identity, adjacent `/model`, directory, conditional Git branch, and a shuffled session-stable tip
|
|
42
42
|
- Cohesive dark theme with coral accents and neutral tool-call containers across pending, success, and error states
|
|
43
43
|
- Animated orange 12-frame activity glyph loop at 120 ms per frame, with orange shuffled Claude-adjacent verbs changing every 2.5 seconds, a gray `(esc to interrupt · thinking)` status with bold `esc`, and a quiet hidden-thinking label
|
|
44
|
-
- Framed multiline editor with Shift+Enter support and
|
|
44
|
+
- Framed multiline editor with Shift+Enter support and slash-command autocomplete; KillerOS preserves an editor factory configured by another extension
|
|
45
45
|
- Responsive footer with polished model/provider identity, plain-language context, and active goal state remaining; reasoning, Git branch, elapsed time, cost, and path cut down by available width
|
|
46
46
|
- Pi-owned context compaction with active goals continuing from Pi's settled boundary after manual, threshold, and overflow compaction
|
|
47
47
|
- Optional completion sounds after successful or failed settled requests, excluding manual aborts
|
|
48
48
|
- `/variants` selector and direct reasoning-level arguments
|
|
49
49
|
- Codex-style `/goal` with an interactive status/action panel, durable objectives, pause, resume, edit, confirmed panel clearing, automatic continuation, and explicit completion
|
|
50
|
-
-
|
|
50
|
+
- Automatic `/init` guideline synthesis with a frozen safe evidence map, protected existing policy, and the four packaged behavioral sections adapted from `writing-great-guidelines`
|
|
51
51
|
- `question` tool with height-bounded option windows, configured Pi keybindings, live option/input progress, proposal previews, custom answers, history, cancellation, and compact expandable transcript rendering
|
|
52
52
|
- Mid-prompt slash completion with current Pi `0.82.1` commands, extensions, prompts, and skills; paths, URLs, and invalid commands remain plain text
|
|
53
|
-
- `/clear` for a
|
|
54
|
-
- Concise system-prompt guidance
|
|
53
|
+
- Goal-aware `/clear` that confirms, aborts active work, waits for settlement, and starts a new session, plus `/exit` for graceful shutdown
|
|
54
|
+
- Concise system-prompt guidance and supported native concise defaults that preserve explicit provider settings
|
|
55
55
|
|
|
56
56
|
## Commands
|
|
57
57
|
|
|
@@ -72,7 +72,9 @@ Add `-l` to either command for a project-only install. Restart Pi after installi
|
|
|
72
72
|
|
|
73
73
|
`/goal` requires a saved session in TUI or RPC mode. Goal state is stored in versioned session entries on the active branch and restored after reload, resume, fork, or tree navigation. Active goals inject their unchanged objective every turn and continue one settled turn at a time. The model must use KillerOS’s private goal tool to mark verified completion or a blocker repeated across at least three goal turns; final prose alone does not end the loop. Aborted turns, provider failures, and continuation failures pause safely. Replacing unfinished work requires confirmation, and `/goal edit` requires TUI mode.
|
|
74
74
|
|
|
75
|
-
`/init`
|
|
75
|
+
`/init` freezes a safe project-file map and exposes only dedicated read and list operations while it generates root `AGENTS.md`. Git-ignored files, known secret paths, private-key formats, other guidance, dependencies, links, non-regular files, and files outside that map are unavailable. Existing root `AGENTS.md` is separate protected policy: compatible rules are preserved, a real policy conflict leaves it unchanged with a reason, and any concurrent target change aborts installation without replacing the newer file.
|
|
76
|
+
|
|
77
|
+
The generated file uses the four behavioral sections adapted from `writing-great-guidelines`; no external skill installation is required. `/init` asks no setup questions, starts no second model process, writes no other file, and reloads Pi resources only after a successful write.
|
|
76
78
|
|
|
77
79
|
Supported reasoning levels are `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, and `max`. KillerOS limits choices to levels supported by the current model.
|
|
78
80
|
|
|
@@ -90,7 +92,7 @@ Manual `/compact` aborts the current goal turn before summarization, so KillerOS
|
|
|
90
92
|
|
|
91
93
|
For trusted projects, KillerOS loads `AGENTS.local.md` after Pi's shared repository context. A one-line `@path` or `@~/path` file imports personal guidance from another location.
|
|
92
94
|
|
|
93
|
-
Lifecycle hooks are loaded from `.pi/killeros-hooks.json` at session start. Supported event keys are `tool_call`, `tool_result`, and `agent_settled`; matchers are JavaScript regular expressions over Pi tool names. Hook commands run from the repository root with `KILLEROS_EVENT`, `KILLEROS_TOOL`, and `KILLEROS_PAYLOAD` environment variables. Failed `tool_call` hooks block the tool, while later-event failures notify the user.
|
|
95
|
+
Lifecycle hooks are loaded from `.pi/killeros-hooks.json` at session start. Supported event keys are `tool_call`, `tool_result`, and `agent_settled`; matchers are JavaScript regular expressions over Pi tool names. Hook commands run from the repository root with `KILLEROS_EVENT`, `KILLEROS_TOOL`, and `KILLEROS_PAYLOAD` environment variables. Failed `tool_call` hooks block the tool, while later-event failures notify the user. Aborting the parent request stops the hook process tree with bounded graceful and forced cleanup without reporting cancellation as a hook failure.
|
|
94
96
|
|
|
95
97
|
## Behavior by mode
|
|
96
98
|
|
package/killeros/commands.ts
CHANGED
|
@@ -8,8 +8,9 @@ async function confirmNewSession(ctx: ExtensionCommandContext): Promise<boolean>
|
|
|
8
8
|
|
|
9
9
|
export function registerAliases(pi: ExtensionAPI): void {
|
|
10
10
|
const startNewSession = async (_args: string, ctx: ExtensionCommandContext): Promise<void> => {
|
|
11
|
-
await ctx.waitForIdle();
|
|
12
11
|
if (!await confirmNewSession(ctx)) return;
|
|
12
|
+
if (!ctx.isIdle()) ctx.abort();
|
|
13
|
+
await ctx.waitForIdle();
|
|
13
14
|
await ctx.newSession();
|
|
14
15
|
};
|
|
15
16
|
pi.registerCommand("clear", { description: "Start a new session after confirmation", handler: startNewSession });
|
|
@@ -54,13 +55,6 @@ const BUILTIN_COMMANDS: ReadonlyArray<{ name: string; description: string }> = [
|
|
|
54
55
|
{ name: "quit", description: "Quit Pi" },
|
|
55
56
|
];
|
|
56
57
|
|
|
57
|
-
export function availableCommandNames(pi: ExtensionAPI): ReadonlySet<string> {
|
|
58
|
-
return new Set([
|
|
59
|
-
...BUILTIN_COMMANDS.map((command) => command.name),
|
|
60
|
-
...pi.getCommands().map((command) => command.name),
|
|
61
|
-
]);
|
|
62
|
-
}
|
|
63
|
-
|
|
64
58
|
const COMMAND_SYNTAX_HINTS: Readonly<Record<string, string>> = {
|
|
65
59
|
goal: "/goal [objective|clear|edit|pause|resume]",
|
|
66
60
|
variants: "/variants [level]",
|
package/killeros/concise.ts
CHANGED
|
@@ -41,22 +41,26 @@ function applyConciseModelSettings(payload: unknown, api: unknown, modelId: unkn
|
|
|
41
41
|
const supportsVerbosity = api === "openai-codex-responses"
|
|
42
42
|
|| api === "openai-responses" && typeof modelId === "string" && /^gpt-5(?:[.-]|$)/u.test(modelId);
|
|
43
43
|
let updated = payload;
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
text: { ...(isRecord(payload.text) ? payload.text : {}), verbosity: "low" },
|
|
48
|
-
};
|
|
44
|
+
const text = isRecord(payload.text) ? payload.text : undefined;
|
|
45
|
+
if (supportsVerbosity && !Object.hasOwn(text ?? {}, "verbosity")) {
|
|
46
|
+
updated = { ...updated, text: { ...(text ?? {}), verbosity: "low" } };
|
|
49
47
|
}
|
|
50
|
-
|
|
51
|
-
|
|
48
|
+
const reasoning = isRecord(payload.reasoning) ? payload.reasoning : undefined;
|
|
49
|
+
if (supportsSummary && reasoning && !Object.hasOwn(reasoning, "summary")) {
|
|
50
|
+
updated = { ...updated, reasoning: { ...reasoning, summary: "concise" } };
|
|
52
51
|
}
|
|
53
52
|
return updated;
|
|
54
53
|
}
|
|
55
54
|
|
|
56
|
-
export function
|
|
55
|
+
export function isConciseEnabled(): boolean {
|
|
57
56
|
return true;
|
|
58
57
|
}
|
|
59
58
|
|
|
59
|
+
/** @deprecated Use isConciseEnabled instead. */
|
|
60
|
+
export function isConcisedEnabled(): boolean {
|
|
61
|
+
return isConciseEnabled();
|
|
62
|
+
}
|
|
63
|
+
|
|
60
64
|
export function registerConcisePrompt(pi: ExtensionAPI): void {
|
|
61
65
|
pi.on("before_agent_start", (event) => ({
|
|
62
66
|
systemPrompt: `${event.systemPrompt}\n\n${CONCISE_SYSTEM_PROMPT}`,
|
package/killeros/goals.ts
CHANGED
|
@@ -3,7 +3,6 @@ import { Text } from "@earendil-works/pi-tui";
|
|
|
3
3
|
import { Type } from "typebox";
|
|
4
4
|
import { MAX_NODE_TIMER_MS } from "./limits.ts";
|
|
5
5
|
import { BoundedText } from "./bounded-text.ts";
|
|
6
|
-
import { CONCISE_SYSTEM_PROMPT } from "./concise.ts";
|
|
7
6
|
import { formatTime, formatTokens } from "./display.ts";
|
|
8
7
|
import { reportError } from "./errors.ts";
|
|
9
8
|
import { resolvePersonalInstructions } from "./personal-instructions.ts";
|
|
@@ -355,7 +354,6 @@ function goalContinuationMessage(state: GoalState, ctx: ExtensionContext): strin
|
|
|
355
354
|
sections.push(`<personal_instructions source=${JSON.stringify(personal.source)}>\n${personal.content}\n</personal_instructions>`);
|
|
356
355
|
}
|
|
357
356
|
}
|
|
358
|
-
sections.push(CONCISE_SYSTEM_PROMPT);
|
|
359
357
|
return sections.join("\n\n");
|
|
360
358
|
}
|
|
361
359
|
|
package/killeros/hooks.ts
CHANGED
|
@@ -22,6 +22,7 @@ interface HookExecutionResult {
|
|
|
22
22
|
stdout: string;
|
|
23
23
|
stderr: string;
|
|
24
24
|
timedOut: boolean;
|
|
25
|
+
cancelled: boolean;
|
|
25
26
|
exitUnconfirmed: boolean;
|
|
26
27
|
}
|
|
27
28
|
|
|
@@ -109,7 +110,17 @@ function terminateHookProcess(child: ReturnType<typeof spawn>, force: boolean):
|
|
|
109
110
|
}
|
|
110
111
|
}
|
|
111
112
|
|
|
112
|
-
export function executeHook(
|
|
113
|
+
export function executeHook(
|
|
114
|
+
command: string,
|
|
115
|
+
cwd: string,
|
|
116
|
+
environment: Record<string, string>,
|
|
117
|
+
timeoutMs = 30_000,
|
|
118
|
+
spawnProcess: typeof spawn = spawn,
|
|
119
|
+
signal?: AbortSignal,
|
|
120
|
+
): Promise<HookExecutionResult> {
|
|
121
|
+
if (signal?.aborted) {
|
|
122
|
+
return Promise.resolve({ code: 130, stdout: "", stderr: "", timedOut: false, cancelled: true, exitUnconfirmed: false });
|
|
123
|
+
}
|
|
113
124
|
return new Promise((resolve) => {
|
|
114
125
|
const child = spawnProcess(command, {
|
|
115
126
|
cwd,
|
|
@@ -122,36 +133,48 @@ export function executeHook(command: string, cwd: string, environment: Record<st
|
|
|
122
133
|
let stdout = "";
|
|
123
134
|
let stderr = "";
|
|
124
135
|
let completed = false;
|
|
125
|
-
let
|
|
126
|
-
let exitUnconfirmed = false;
|
|
136
|
+
let termination: "timeout" | "cancelled" | undefined;
|
|
127
137
|
let timer: NodeJS.Timeout | undefined;
|
|
128
138
|
let forceTimer: NodeJS.Timeout | undefined;
|
|
129
139
|
let settleTimer: NodeJS.Timeout | undefined;
|
|
130
|
-
const finish = (code: number,
|
|
140
|
+
const finish = (code: number, exitUnconfirmed = false): void => {
|
|
131
141
|
if (completed) return;
|
|
132
142
|
completed = true;
|
|
133
|
-
exitUnconfirmed = unconfirmed;
|
|
134
143
|
if (timer) clearTimeout(timer);
|
|
135
144
|
if (forceTimer) clearTimeout(forceTimer);
|
|
136
145
|
if (settleTimer) clearTimeout(settleTimer);
|
|
137
|
-
|
|
146
|
+
signal?.removeEventListener("abort", abort);
|
|
147
|
+
resolve({
|
|
148
|
+
code,
|
|
149
|
+
stdout,
|
|
150
|
+
stderr,
|
|
151
|
+
timedOut: termination === "timeout",
|
|
152
|
+
cancelled: termination === "cancelled",
|
|
153
|
+
exitUnconfirmed,
|
|
154
|
+
});
|
|
138
155
|
};
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
finish(timedOut ? 124 : 1);
|
|
144
|
-
});
|
|
145
|
-
child.once("close", (code) => finish(timedOut ? 124 : code ?? 1));
|
|
146
|
-
timer = setTimeout(() => {
|
|
147
|
-
timedOut = true;
|
|
156
|
+
const terminationCode = (): number => termination === "cancelled" ? 130 : 124;
|
|
157
|
+
const beginTermination = (reason: "timeout" | "cancelled"): void => {
|
|
158
|
+
if (completed || termination) return;
|
|
159
|
+
termination = reason;
|
|
148
160
|
terminateHookProcess(child, false);
|
|
149
161
|
forceTimer = setTimeout(() => {
|
|
150
162
|
if (completed) return;
|
|
151
163
|
terminateHookProcess(child, true);
|
|
152
|
-
settleTimer = setTimeout(() => finish(
|
|
164
|
+
settleTimer = setTimeout(() => finish(terminationCode(), true), 1_000);
|
|
153
165
|
}, 1_000);
|
|
154
|
-
}
|
|
166
|
+
};
|
|
167
|
+
const abort = (): void => beginTermination("cancelled");
|
|
168
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
169
|
+
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); });
|
|
172
|
+
child.on("error", (error) => {
|
|
173
|
+
stderr = appendBounded(stderr, error.message);
|
|
174
|
+
finish(termination ? terminationCode() : 1);
|
|
175
|
+
});
|
|
176
|
+
child.once("close", (code) => finish(termination ? terminationCode() : code ?? 1));
|
|
177
|
+
timer = setTimeout(() => beginTermination("timeout"), Math.max(1_000, Math.min(timeoutMs, 300_000)));
|
|
155
178
|
});
|
|
156
179
|
}
|
|
157
180
|
|
|
@@ -180,7 +203,10 @@ export function registerLifecycleHooks(pi: ExtensionAPI): void {
|
|
|
180
203
|
ctx.cwd,
|
|
181
204
|
hookEnvironment("tool_call", event.toolName, event.input),
|
|
182
205
|
hook.timeoutMs,
|
|
206
|
+
spawn,
|
|
207
|
+
ctx.signal,
|
|
183
208
|
);
|
|
209
|
+
if (result.cancelled) return { block: true, reason: "Hook cancelled because the parent request was aborted" };
|
|
184
210
|
if (result.code !== 0) {
|
|
185
211
|
const reason = hookFailureMessage(hook, result);
|
|
186
212
|
ctx.ui.notify(reason, "error");
|
|
@@ -200,7 +226,10 @@ export function registerLifecycleHooks(pi: ExtensionAPI): void {
|
|
|
200
226
|
isError: event.isError,
|
|
201
227
|
}),
|
|
202
228
|
hook.timeoutMs,
|
|
229
|
+
spawn,
|
|
230
|
+
ctx.signal,
|
|
203
231
|
);
|
|
232
|
+
if (result.cancelled) break;
|
|
204
233
|
if (result.code !== 0) ctx.ui.notify(hookFailureMessage(hook, result), "error");
|
|
205
234
|
}
|
|
206
235
|
});
|
|
@@ -212,7 +241,10 @@ export function registerLifecycleHooks(pi: ExtensionAPI): void {
|
|
|
212
241
|
ctx.cwd,
|
|
213
242
|
hookEnvironment("agent_settled"),
|
|
214
243
|
hook.timeoutMs,
|
|
244
|
+
spawn,
|
|
245
|
+
ctx.signal,
|
|
215
246
|
);
|
|
247
|
+
if (result.cancelled) break;
|
|
216
248
|
if (result.code !== 0) ctx.ui.notify(hookFailureMessage(hook, result), "error");
|
|
217
249
|
}
|
|
218
250
|
});
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { promises as fs } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
export const INIT_READ_TOOL = "killeros_init_read";
|
|
6
|
+
export const INIT_LIST_TOOL = "killeros_init_list";
|
|
7
|
+
|
|
8
|
+
const SNAPSHOT_LIMIT = 40 * 1024;
|
|
9
|
+
const AUTOMATIC_FILE_LIMIT = 8 * 1024;
|
|
10
|
+
const READ_LIMIT = 32 * 1024;
|
|
11
|
+
const PATH_LIMIT = 400;
|
|
12
|
+
const DIRECTORY_LIMIT = 120;
|
|
13
|
+
const DEPTH_LIMIT = 4;
|
|
14
|
+
const EXCLUDED_DIRS = new Set([
|
|
15
|
+
".agents", ".claude", ".git", ".next", ".pi", ".pytest_cache", ".turbo", ".venv", "__pycache__", "archive", "build", "coverage", "data", "dist", "logs", "node_modules", "target", "test-results", "vendor",
|
|
16
|
+
]);
|
|
17
|
+
const EXCLUDED_GUIDANCE = new Set([
|
|
18
|
+
".cursorrules", "agents.md", "agents.local.md", "claude.md", "claude.local.md", "copilot-instructions.md", "gemini.md", "memory.md", "skill.md",
|
|
19
|
+
]);
|
|
20
|
+
const ROOT_EVIDENCE = [
|
|
21
|
+
"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",
|
|
22
|
+
] as const;
|
|
23
|
+
const NESTED_EVIDENCE = new Set(["package.json", "pyproject.toml", "requirements.txt", "Cargo.toml", "go.mod"]);
|
|
24
|
+
|
|
25
|
+
export interface InitEvidenceIndex {
|
|
26
|
+
projectRoot: string;
|
|
27
|
+
canonicalPaths: ReadonlyMap<string, string>;
|
|
28
|
+
snapshot: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface InitEvidenceBuildResult {
|
|
32
|
+
index: InitEvidenceIndex;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function evidenceKey(relativePath: string): string {
|
|
36
|
+
const normalized = relativePath.replaceAll("\\", "/");
|
|
37
|
+
return process.platform === "win32" ? normalized.toLocaleLowerCase() : normalized;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function sensitiveEvidencePath(relativePath: string): boolean {
|
|
41
|
+
const normalized = relativePath.replaceAll("\\", "/").toLocaleLowerCase();
|
|
42
|
+
const name = path.posix.basename(normalized);
|
|
43
|
+
return /^\.env(?:\.|$)/u.test(name)
|
|
44
|
+
|| [".npmrc", ".pypirc", ".netrc", "id_rsa", "id_ed25519", "credentials.json"].includes(name)
|
|
45
|
+
|| /^service-account.*\.json$/u.test(name)
|
|
46
|
+
|| /\.(?:pem|key|p12|pfx|jks|keystore)$/u.test(name);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function excludedPath(relativePath: string): boolean {
|
|
50
|
+
const segments = relativePath.replaceAll("\\", "/").split("/");
|
|
51
|
+
return segments.some((segment, index) =>
|
|
52
|
+
(index < segments.length - 1 && EXCLUDED_DIRS.has(segment.toLocaleLowerCase()))
|
|
53
|
+
|| EXCLUDED_GUIDANCE.has(segment.toLocaleLowerCase()))
|
|
54
|
+
|| sensitiveEvidencePath(relativePath);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function collectCandidates(projectRoot: string): Promise<string[]> {
|
|
58
|
+
const files: string[] = [];
|
|
59
|
+
const queue: Array<{ relativePath: string; depth: number }> = [{ relativePath: "", depth: 0 }];
|
|
60
|
+
let directoriesRead = 0;
|
|
61
|
+
while (queue.length && files.length < PATH_LIMIT && directoriesRead < DIRECTORY_LIMIT) {
|
|
62
|
+
const current = queue.shift()!;
|
|
63
|
+
directoriesRead += 1;
|
|
64
|
+
let entries;
|
|
65
|
+
try {
|
|
66
|
+
entries = await fs.readdir(path.join(projectRoot, current.relativePath), { withFileTypes: true });
|
|
67
|
+
} catch (error) {
|
|
68
|
+
if (!current.relativePath) throw error;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
entries.sort((left, right) => left.name.localeCompare(right.name));
|
|
72
|
+
for (const entry of entries) {
|
|
73
|
+
if (files.length >= PATH_LIMIT) break;
|
|
74
|
+
const relativePath = path.posix.join(current.relativePath.replaceAll("\\", "/"), entry.name);
|
|
75
|
+
if (entry.isDirectory()) {
|
|
76
|
+
if (current.depth < DEPTH_LIMIT && !EXCLUDED_DIRS.has(entry.name.toLocaleLowerCase())) {
|
|
77
|
+
queue.push({ relativePath, depth: current.depth + 1 });
|
|
78
|
+
}
|
|
79
|
+
} else if (entry.isFile() && !excludedPath(relativePath)) {
|
|
80
|
+
try {
|
|
81
|
+
const stat = await fs.lstat(path.join(projectRoot, relativePath));
|
|
82
|
+
if (stat.isFile() && !stat.isSymbolicLink() && stat.nlink === 1) files.push(relativePath);
|
|
83
|
+
} catch {
|
|
84
|
+
// Files may disappear while the bounded map is collected.
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return files;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function gitIgnoredPaths(projectRoot: string, candidates: readonly string[]): Promise<ReadonlySet<string>> {
|
|
93
|
+
if (!candidates.length) return new Set();
|
|
94
|
+
return new Promise((resolve) => {
|
|
95
|
+
let settled = false;
|
|
96
|
+
let stdout = Buffer.alloc(0);
|
|
97
|
+
const child = spawn("git", ["-C", projectRoot, "check-ignore", "--stdin", "-z"], {
|
|
98
|
+
shell: false,
|
|
99
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
100
|
+
windowsHide: true,
|
|
101
|
+
});
|
|
102
|
+
const finish = (value: ReadonlySet<string>): void => {
|
|
103
|
+
if (settled) return;
|
|
104
|
+
settled = true;
|
|
105
|
+
clearTimeout(timer);
|
|
106
|
+
resolve(value);
|
|
107
|
+
};
|
|
108
|
+
const timer = setTimeout(() => {
|
|
109
|
+
child.kill("SIGKILL");
|
|
110
|
+
finish(new Set());
|
|
111
|
+
}, 2_000);
|
|
112
|
+
child.stdout.on("data", (chunk: Buffer) => {
|
|
113
|
+
if (stdout.length <= 256 * 1024) stdout = Buffer.concat([stdout, chunk]);
|
|
114
|
+
});
|
|
115
|
+
child.once("error", () => finish(new Set()));
|
|
116
|
+
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());
|
|
119
|
+
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)));
|
|
123
|
+
});
|
|
124
|
+
child.stdin.on("error", () => {});
|
|
125
|
+
child.stdin.end(`${candidates.join("\0")}\0`);
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function appendWithinLimit(current: string, section: string, limit: number): string {
|
|
130
|
+
const remaining = limit - Buffer.byteLength(current, "utf8");
|
|
131
|
+
if (remaining <= 0) return current;
|
|
132
|
+
const bytes = Buffer.from(section, "utf8");
|
|
133
|
+
return current + bytes.subarray(0, remaining).toString("utf8");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function validateAndRead(projectRoot: string, absolutePath: string, limit: number): Promise<{ content: string; truncated: boolean }> {
|
|
137
|
+
const relative = path.relative(projectRoot, absolutePath);
|
|
138
|
+
if (!relative || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
139
|
+
throw new Error("path is not available to /init");
|
|
140
|
+
}
|
|
141
|
+
let current = projectRoot;
|
|
142
|
+
for (const segment of relative.split(path.sep)) {
|
|
143
|
+
current = path.join(current, segment);
|
|
144
|
+
const stat = await fs.lstat(current);
|
|
145
|
+
if (stat.isSymbolicLink()) throw new Error("/init rejects symbolic-link and junction paths");
|
|
146
|
+
}
|
|
147
|
+
const pathStat = await fs.lstat(absolutePath);
|
|
148
|
+
if (!pathStat.isFile() || pathStat.nlink !== 1) throw new Error("/init rejects linked and non-regular files");
|
|
149
|
+
const handle = await fs.open(absolutePath, "r");
|
|
150
|
+
try {
|
|
151
|
+
const openedStat = await handle.stat();
|
|
152
|
+
if (!openedStat.isFile() || openedStat.nlink !== 1
|
|
153
|
+
|| openedStat.dev !== pathStat.dev || openedStat.ino !== pathStat.ino) {
|
|
154
|
+
throw new Error("/init file changed while it was being opened");
|
|
155
|
+
}
|
|
156
|
+
const buffer = Buffer.alloc(limit + 1);
|
|
157
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
|
158
|
+
const data = buffer.subarray(0, Math.min(bytesRead, limit));
|
|
159
|
+
if (data.includes(0)) throw new Error("/init rejects binary files");
|
|
160
|
+
return { content: data.toString("utf8"), truncated: bytesRead > limit };
|
|
161
|
+
} finally {
|
|
162
|
+
await handle.close();
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function normalizeRequestedPath(requestedPath: string): string {
|
|
167
|
+
if (!requestedPath || requestedPath.trim() !== requestedPath || requestedPath.startsWith("~")
|
|
168
|
+
|| /^file:/iu.test(requestedPath) || path.isAbsolute(requestedPath)) {
|
|
169
|
+
throw new Error("path is not available to /init");
|
|
170
|
+
}
|
|
171
|
+
const normalized = requestedPath.replaceAll("\\", "/");
|
|
172
|
+
if (!normalized || normalized.split("/").some((segment) => segment === ".." || segment === "")) {
|
|
173
|
+
throw new Error("path is not available to /init");
|
|
174
|
+
}
|
|
175
|
+
return normalized.replace(/^\.\//u, "");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export async function buildInitEvidence(projectRoot: string): Promise<InitEvidenceBuildResult> {
|
|
179
|
+
const candidates = await collectCandidates(projectRoot);
|
|
180
|
+
const ignored = await gitIgnoredPaths(projectRoot, candidates);
|
|
181
|
+
const canonicalPaths = new Map<string, string>();
|
|
182
|
+
for (const relativePath of candidates) {
|
|
183
|
+
if (!ignored.has(evidenceKey(relativePath))) canonicalPaths.set(evidenceKey(relativePath), relativePath);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
let snapshot = [
|
|
187
|
+
"# KillerOS repository snapshot",
|
|
188
|
+
"Root AGENTS.md is protected policy and is intentionally not part of this untrusted evidence.",
|
|
189
|
+
"",
|
|
190
|
+
"## Project files",
|
|
191
|
+
[...canonicalPaths.values()].join("\n"),
|
|
192
|
+
].join("\n");
|
|
193
|
+
snapshot = appendWithinLimit("", snapshot, SNAPSHOT_LIMIT);
|
|
194
|
+
const automatic = new Set<string>(ROOT_EVIDENCE);
|
|
195
|
+
for (const relativePath of canonicalPaths.values()) {
|
|
196
|
+
const name = path.posix.basename(relativePath);
|
|
197
|
+
if (NESTED_EVIDENCE.has(name) || /^\.github\/workflows\/[^/]+\.ya?ml$/iu.test(relativePath)) automatic.add(relativePath);
|
|
198
|
+
}
|
|
199
|
+
for (const requested of automatic) {
|
|
200
|
+
const relativePath = canonicalPaths.get(evidenceKey(requested));
|
|
201
|
+
if (!relativePath || Buffer.byteLength(snapshot, "utf8") >= SNAPSHOT_LIMIT) continue;
|
|
202
|
+
try {
|
|
203
|
+
const result = await validateAndRead(projectRoot, path.join(projectRoot, relativePath), AUTOMATIC_FILE_LIMIT);
|
|
204
|
+
const suffix = result.truncated ? "\n[truncated by /init]" : "";
|
|
205
|
+
snapshot = appendWithinLimit(snapshot, `\n\n## ${relativePath}\n${result.content}${suffix}`, SNAPSHOT_LIMIT);
|
|
206
|
+
} catch {
|
|
207
|
+
// A mapped file may become unsafe or disappear before snapshot creation.
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return { index: { projectRoot, canonicalPaths, snapshot } };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export async function readInitEvidence(index: InitEvidenceIndex, requestedPath: string): Promise<string> {
|
|
214
|
+
const normalized = normalizeRequestedPath(requestedPath);
|
|
215
|
+
const relativePath = index.canonicalPaths.get(evidenceKey(normalized));
|
|
216
|
+
if (!relativePath) throw new Error(`${requestedPath} is not available to /init`);
|
|
217
|
+
const result = await validateAndRead(index.projectRoot, path.join(index.projectRoot, relativePath), READ_LIMIT);
|
|
218
|
+
return result.truncated ? `${result.content}\n[truncated by /init at ${READ_LIMIT} bytes]` : result.content;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export async function readGeneratedInitTarget(projectRoot: string, targetPath: string): Promise<string> {
|
|
222
|
+
const result = await validateAndRead(projectRoot, targetPath, READ_LIMIT);
|
|
223
|
+
return result.truncated ? `${result.content}\n[truncated by /init at ${READ_LIMIT} bytes]` : result.content;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function listInitEvidence(index: InitEvidenceIndex, requestedPath = "."): string[] {
|
|
227
|
+
const prefix = requestedPath === "." ? "" : normalizeRequestedPath(requestedPath).replace(/\/$/u, "");
|
|
228
|
+
const prefixWithSlash = prefix ? `${prefix}/` : "";
|
|
229
|
+
const children = new Set<string>();
|
|
230
|
+
let found = !prefix;
|
|
231
|
+
for (const relativePath of index.canonicalPaths.values()) {
|
|
232
|
+
if (!relativePath.startsWith(prefixWithSlash)) continue;
|
|
233
|
+
const remainder = relativePath.slice(prefixWithSlash.length);
|
|
234
|
+
if (!remainder) continue;
|
|
235
|
+
found = true;
|
|
236
|
+
children.add(remainder.split("/")[0]!);
|
|
237
|
+
}
|
|
238
|
+
if (!found) throw new Error(`${requestedPath} is not available to /init`);
|
|
239
|
+
return [...children].sort((left, right) => left.localeCompare(right)).slice(0, PATH_LIMIT);
|
|
240
|
+
}
|