killeros 2.0.18 → 2.0.20
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 +24 -0
- package/Killeros.ts +3 -1
- package/README.md +5 -2
- package/killeros/activity.ts +4 -0
- package/killeros/auto-compaction.ts +40 -10
- package/killeros/codex-fast-state.ts +5 -7
- package/killeros/commands.ts +5 -3
- package/killeros/errors.ts +8 -0
- package/killeros/footer.ts +3 -3
- package/killeros/goals.ts +326 -116
- package/killeros/handoff.ts +40 -10
- package/killeros/hooks.ts +121 -48
- package/killeros/init-evidence.ts +4 -2
- package/killeros/init-target.ts +28 -20
- package/killeros/init.ts +12 -5
- package/killeros/personal-instructions.ts +3 -4
- package/killeros/question.ts +15 -8
- package/killeros/runtime.ts +42 -7
- package/killeros/settings.ts +8 -5
- package/killeros/shell-ui.ts +14 -6
- package/package.json +6 -3
package/killeros/handoff.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { contentText } from "@earendil-works/pi-ai";
|
|
2
2
|
import { BorderedLoader, convertToLlm, type ExtensionAPI, type ExtensionCommandContext, serializeConversation, sessionEntryToContextMessages } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
import { reportError } from "./errors.ts";
|
|
3
|
+
import { errorMessage, reportError } from "./errors.ts";
|
|
4
4
|
import type { GoalRuntime } from "./runtime.ts";
|
|
5
|
+
import { createKillerosSettingsStore, type KillerosSettings } from "./settings.ts";
|
|
5
6
|
import { safeTerminalText } from "./safe-terminal-text.ts";
|
|
6
7
|
|
|
7
8
|
const HANDOFF_UNAVAILABLE = "/handoff is not available while an agent or /goal is running.";
|
|
9
|
+
/** Output-token budget with headroom for reasoning traces plus all ten sections. */
|
|
10
|
+
export const DEFAULT_HANDOFF_MAX_TOKENS = 8_192;
|
|
8
11
|
const HANDOFF_SECTIONS = [
|
|
9
12
|
"Objective",
|
|
10
13
|
"Current state",
|
|
@@ -69,6 +72,20 @@ function sessionName(sourceName: string | undefined, focus: string, document: st
|
|
|
69
72
|
return `${shortBase || "Handoff"} · handoff`;
|
|
70
73
|
}
|
|
71
74
|
|
|
75
|
+
/** Accepts only positive integers. */
|
|
76
|
+
function isPositiveInt(value: unknown): value is number {
|
|
77
|
+
return typeof value === "number" && Number.isInteger(value) && value > 0;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function positiveIntOr(value: unknown, fallback: number): number {
|
|
81
|
+
return isPositiveInt(value) ? value : fallback;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Resolves the summary budget: explicit option first, then killeros.json, then the default. */
|
|
85
|
+
export function resolveHandoffMaxTokens(settings: Readonly<KillerosSettings>, override?: number): number {
|
|
86
|
+
return positiveIntOr(override, positiveIntOr(settings.handoffMaxTokens, DEFAULT_HANDOFF_MAX_TOKENS));
|
|
87
|
+
}
|
|
88
|
+
|
|
72
89
|
/** Checks that the model returned every section needed to continue safely. */
|
|
73
90
|
function hasRequiredHandoffContent(document: string, focus: string): boolean {
|
|
74
91
|
if (focus && !document.includes(focus)) return false;
|
|
@@ -82,16 +99,16 @@ function hasRequiredHandoffContent(document: string, focus: string): boolean {
|
|
|
82
99
|
});
|
|
83
100
|
}
|
|
84
101
|
|
|
85
|
-
/** Generates
|
|
86
|
-
async function generateHandoffSummary(
|
|
102
|
+
/** Generates a handoff summary; throws named errors for truncation and provider failures. */
|
|
103
|
+
export async function generateHandoffSummary(
|
|
87
104
|
ctx: ExtensionCommandContext,
|
|
88
105
|
conversation: string,
|
|
89
106
|
focus: string,
|
|
90
|
-
signal?: AbortSignal,
|
|
107
|
+
options: { maxTokens: number; signal?: AbortSignal },
|
|
91
108
|
): Promise<string> {
|
|
92
109
|
if (!ctx.model) throw new Error("No current model is available");
|
|
93
110
|
|
|
94
|
-
signal?.throwIfAborted();
|
|
111
|
+
options.signal?.throwIfAborted();
|
|
95
112
|
|
|
96
113
|
const response = await ctx.modelRegistry.complete(ctx.model, {
|
|
97
114
|
systemPrompt: HANDOFF_SYSTEM_PROMPT,
|
|
@@ -101,10 +118,13 @@ async function generateHandoffSummary(
|
|
|
101
118
|
timestamp: Date.now(),
|
|
102
119
|
}],
|
|
103
120
|
}, {
|
|
104
|
-
maxTokens:
|
|
105
|
-
signal,
|
|
121
|
+
maxTokens: options.maxTokens,
|
|
122
|
+
signal: options.signal,
|
|
106
123
|
});
|
|
107
124
|
if (response.stopReason === "error") throw new Error(response.errorMessage || "Handoff summary failed");
|
|
125
|
+
if (response.stopReason === "length") {
|
|
126
|
+
throw new Error(`The handoff summary exceeded its ${options.maxTokens}-token output budget. Shorten the source session or raise the handoff token budget.`);
|
|
127
|
+
}
|
|
108
128
|
if (response.stopReason !== "stop") throw new Error("The handoff summary did not finish");
|
|
109
129
|
|
|
110
130
|
const summary = safeTerminalText(contentText(response.content)).trim();
|
|
@@ -113,7 +133,7 @@ async function generateHandoffSummary(
|
|
|
113
133
|
}
|
|
114
134
|
|
|
115
135
|
/** Registers the idle-only command that summarizes context into a child session. */
|
|
116
|
-
export function registerHandoff(pi: ExtensionAPI, goalRuntime: GoalRuntime): void {
|
|
136
|
+
export function registerHandoff(pi: ExtensionAPI, goalRuntime: GoalRuntime, handoffMaxTokens?: number): void {
|
|
117
137
|
pi.registerCommand("handoff", {
|
|
118
138
|
description: "Create a fresh session with a continuation handoff",
|
|
119
139
|
handler: async (args, ctx) => {
|
|
@@ -136,6 +156,16 @@ export function registerHandoff(pi: ExtensionAPI, goalRuntime: GoalRuntime): voi
|
|
|
136
156
|
const conversation = serializeConversation(convertToLlm(messages));
|
|
137
157
|
if (!conversation.trim()) throw new Error("No usable session context is available");
|
|
138
158
|
focus = safeTerminalText(args).trim();
|
|
159
|
+
let maxTokens = handoffMaxTokens;
|
|
160
|
+
if (!isPositiveInt(maxTokens)) {
|
|
161
|
+
let settings: KillerosSettings = {};
|
|
162
|
+
try {
|
|
163
|
+
settings = createKillerosSettingsStore().load();
|
|
164
|
+
} catch (error) {
|
|
165
|
+
ctx.ui.notify(`killeros.json could not be read; using the default handoff budget: ${errorMessage(error)}`, "warning");
|
|
166
|
+
}
|
|
167
|
+
maxTokens = resolveHandoffMaxTokens(settings);
|
|
168
|
+
}
|
|
139
169
|
const generation = ctx.mode === "tui"
|
|
140
170
|
? await ctx.ui.custom<HandoffGenerationResult>((tui, theme, _keybindings, done) => {
|
|
141
171
|
const loader = new BorderedLoader(tui, theme, "Generating handoff...");
|
|
@@ -146,12 +176,12 @@ export function registerHandoff(pi: ExtensionAPI, goalRuntime: GoalRuntime): voi
|
|
|
146
176
|
done(result);
|
|
147
177
|
};
|
|
148
178
|
loader.onAbort = () => finish({ kind: "cancelled" });
|
|
149
|
-
generateHandoffSummary(ctx, conversation, focus, loader.signal)
|
|
179
|
+
generateHandoffSummary(ctx, conversation, focus, { maxTokens, signal: loader.signal })
|
|
150
180
|
.then((summary) => finish({ kind: "summary", summary }))
|
|
151
181
|
.catch((error: unknown) => finish({ kind: "error", error }));
|
|
152
182
|
return loader;
|
|
153
183
|
})
|
|
154
|
-
: { kind: "summary", summary: await generateHandoffSummary(ctx, conversation, focus) } as const;
|
|
184
|
+
: { kind: "summary", summary: await generateHandoffSummary(ctx, conversation, focus, { maxTokens }) } as const;
|
|
155
185
|
if (generation.kind === "cancelled") {
|
|
156
186
|
ctx.ui.notify("Handoff cancelled", "info");
|
|
157
187
|
return;
|
package/killeros/hooks.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
spawn,
|
|
3
|
+
type SpawnOptionsWithStdioTuple,
|
|
4
|
+
} from "node:child_process";
|
|
2
5
|
import { closeSync, existsSync, fstatSync, lstatSync, openSync, readSync, realpathSync } from "node:fs";
|
|
3
6
|
import path from "node:path";
|
|
4
7
|
import { StringDecoder } from "node:string_decoder";
|
|
@@ -27,6 +30,30 @@ interface HookExecutionResult {
|
|
|
27
30
|
exitUnconfirmed: boolean;
|
|
28
31
|
}
|
|
29
32
|
|
|
33
|
+
interface HookOutputStream {
|
|
34
|
+
on(event: "data", listener: (chunk: Buffer | string) => void): unknown;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface HookChildProcess {
|
|
38
|
+
readonly pid?: number;
|
|
39
|
+
readonly stdout: HookOutputStream;
|
|
40
|
+
readonly stderr: HookOutputStream;
|
|
41
|
+
kill(signal?: NodeJS.Signals | number): boolean;
|
|
42
|
+
on(event: "error", listener: (error: Error) => void): unknown;
|
|
43
|
+
once(event: "close", listener: (code: number | null) => void): unknown;
|
|
44
|
+
}
|
|
45
|
+
type HookSpawnOptions = SpawnOptionsWithStdioTuple<"ignore", "pipe", "pipe">;
|
|
46
|
+
export type HookSpawnProcess = (command: string, options: HookSpawnOptions) => HookChildProcess;
|
|
47
|
+
|
|
48
|
+
export interface ExecuteHookOptions {
|
|
49
|
+
command: string;
|
|
50
|
+
cwd: string;
|
|
51
|
+
environment: Record<string, string>;
|
|
52
|
+
timeoutMs?: number;
|
|
53
|
+
spawnProcess?: HookSpawnProcess;
|
|
54
|
+
signal?: AbortSignal;
|
|
55
|
+
}
|
|
56
|
+
|
|
30
57
|
const HOOK_EVENTS: readonly KillerosHookEvent[] = ["tool_call", "tool_result", "agent_settled"];
|
|
31
58
|
const HOOK_CONFIG_LIMIT = 64 * 1024;
|
|
32
59
|
const HOOK_OUTPUT_LIMIT = 16 * 1024;
|
|
@@ -78,6 +105,10 @@ function readHookConfig(configPath: string, projectRoot: string): string {
|
|
|
78
105
|
}
|
|
79
106
|
}
|
|
80
107
|
|
|
108
|
+
function isUnknownRecord(value: unknown): value is Record<string, unknown> {
|
|
109
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
110
|
+
}
|
|
111
|
+
|
|
81
112
|
function loadKillerosHooks(ctx: ExtensionContext): KillerosHookConfig {
|
|
82
113
|
const configPath = path.join(ctx.cwd, CONFIG_DIR_NAME, "killeros-hooks.json");
|
|
83
114
|
if (!existsSync(configPath)) return {};
|
|
@@ -88,38 +119,52 @@ function loadKillerosHooks(ctx: ExtensionContext): KillerosHookConfig {
|
|
|
88
119
|
}
|
|
89
120
|
|
|
90
121
|
try {
|
|
91
|
-
const parsed = JSON.parse(readHookConfig(configPath, ctx.cwd))
|
|
122
|
+
const parsed: unknown = JSON.parse(readHookConfig(configPath, ctx.cwd));
|
|
123
|
+
if (!isUnknownRecord(parsed)) throw new Error("Hook config must contain a JSON object");
|
|
124
|
+
const parsedHooks = parsed.hooks;
|
|
125
|
+
if (parsedHooks !== undefined && !isUnknownRecord(parsedHooks)) {
|
|
126
|
+
throw new Error("Hook config hooks must contain a JSON object");
|
|
127
|
+
}
|
|
128
|
+
|
|
92
129
|
const hooks: KillerosHookConfig["hooks"] = {};
|
|
93
130
|
for (const event of HOOK_EVENTS) {
|
|
94
|
-
const candidates =
|
|
131
|
+
const candidates = parsedHooks?.[event];
|
|
95
132
|
if (!Array.isArray(candidates)) continue;
|
|
96
|
-
|
|
97
|
-
|
|
133
|
+
const accepted: KillerosHook[] = [];
|
|
134
|
+
for (const [index, candidate] of candidates.entries()) {
|
|
135
|
+
if (!isUnknownRecord(candidate)) {
|
|
136
|
+
ctx.ui.notify(`Ignored invalid ${event} hook ${index + 1} in ${displayPath}`, "warning");
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
const { command, matcher, timeoutMs } = candidate;
|
|
140
|
+
if (event === "agent_settled" && matcher !== undefined) {
|
|
98
141
|
ctx.ui.notify(`Ignored ${event} hook ${index + 1}: matchers are only valid for tool events`, "warning");
|
|
99
|
-
|
|
142
|
+
continue;
|
|
100
143
|
}
|
|
101
|
-
if (
|
|
144
|
+
if (timeoutMs !== undefined && (typeof timeoutMs !== "number" || !Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > HOOK_TIMEOUT_MAX_MS)) {
|
|
102
145
|
ctx.ui.notify(`Ignored ${event} hook ${index + 1}: timeoutMs must be an integer from 1 to ${HOOK_TIMEOUT_MAX_MS}`, "warning");
|
|
103
|
-
|
|
146
|
+
continue;
|
|
104
147
|
}
|
|
105
|
-
|
|
106
|
-
&& typeof
|
|
107
|
-
&& hook.command.trim().length > 0
|
|
108
|
-
&& (hook.matcher === undefined || typeof hook.matcher === "string");
|
|
109
|
-
if (!valid) {
|
|
148
|
+
if (typeof command !== "string" || command.trim().length === 0
|
|
149
|
+
|| matcher !== undefined && typeof matcher !== "string") {
|
|
110
150
|
ctx.ui.notify(`Ignored invalid ${event} hook ${index + 1} in ${displayPath}`, "warning");
|
|
111
|
-
|
|
151
|
+
continue;
|
|
112
152
|
}
|
|
113
|
-
if (
|
|
153
|
+
if (matcher && matcher !== "*") {
|
|
114
154
|
try {
|
|
115
|
-
new RegExp(
|
|
155
|
+
new RegExp(matcher, "u");
|
|
116
156
|
} catch {
|
|
117
|
-
ctx.ui.notify(`Ignored ${event} hook ${index + 1}: invalid matcher ${JSON.stringify(
|
|
118
|
-
|
|
157
|
+
ctx.ui.notify(`Ignored ${event} hook ${index + 1}: invalid matcher ${JSON.stringify(matcher)}`, "warning");
|
|
158
|
+
continue;
|
|
119
159
|
}
|
|
120
160
|
}
|
|
121
|
-
|
|
122
|
-
|
|
161
|
+
accepted.push({
|
|
162
|
+
command,
|
|
163
|
+
...(matcher === undefined ? {} : { matcher }),
|
|
164
|
+
...(timeoutMs === undefined ? {} : { timeoutMs }),
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
hooks[event] = accepted;
|
|
123
168
|
}
|
|
124
169
|
return { hooks };
|
|
125
170
|
} catch (error) {
|
|
@@ -152,7 +197,7 @@ function appendBounded(output: HookOutputBuffer, chunk: Buffer | string): void {
|
|
|
152
197
|
output.text += output.decoder.write(captured);
|
|
153
198
|
}
|
|
154
199
|
|
|
155
|
-
function terminateHookProcess(child:
|
|
200
|
+
function terminateHookProcess(child: HookChildProcess, force: boolean): void {
|
|
156
201
|
if (process.platform === "win32" && force && child.pid) {
|
|
157
202
|
const killer = spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], {
|
|
158
203
|
shell: false,
|
|
@@ -177,18 +222,46 @@ function terminateHookProcess(child: ReturnType<typeof spawn>, force: boolean):
|
|
|
177
222
|
}
|
|
178
223
|
}
|
|
179
224
|
|
|
225
|
+
export function executeHook(options: ExecuteHookOptions): Promise<HookExecutionResult>;
|
|
226
|
+
/** @deprecated Use the object-argument form. The positional adapter will be removed in the next major release. */
|
|
180
227
|
export function executeHook(
|
|
181
228
|
command: string,
|
|
182
229
|
cwd: string,
|
|
183
230
|
environment: Record<string, string>,
|
|
184
|
-
timeoutMs
|
|
185
|
-
spawnProcess
|
|
231
|
+
timeoutMs?: number,
|
|
232
|
+
spawnProcess?: HookSpawnProcess,
|
|
186
233
|
signal?: AbortSignal,
|
|
234
|
+
): Promise<HookExecutionResult>;
|
|
235
|
+
export function executeHook(
|
|
236
|
+
optionsOrCommand: ExecuteHookOptions | string,
|
|
237
|
+
legacyCwd?: string,
|
|
238
|
+
legacyEnvironment?: Record<string, string>,
|
|
239
|
+
legacyTimeoutMs?: number,
|
|
240
|
+
legacySpawnProcess?: HookSpawnProcess,
|
|
241
|
+
legacySignal?: AbortSignal,
|
|
187
242
|
): Promise<HookExecutionResult> {
|
|
243
|
+
const options: ExecuteHookOptions = typeof optionsOrCommand === "string"
|
|
244
|
+
? {
|
|
245
|
+
command: optionsOrCommand,
|
|
246
|
+
cwd: legacyCwd ?? process.cwd(),
|
|
247
|
+
environment: legacyEnvironment ?? {},
|
|
248
|
+
...(legacyTimeoutMs === undefined ? {} : { timeoutMs: legacyTimeoutMs }),
|
|
249
|
+
...(legacySpawnProcess === undefined ? {} : { spawnProcess: legacySpawnProcess }),
|
|
250
|
+
...(legacySignal === undefined ? {} : { signal: legacySignal }),
|
|
251
|
+
}
|
|
252
|
+
: optionsOrCommand;
|
|
253
|
+
const {
|
|
254
|
+
command,
|
|
255
|
+
cwd,
|
|
256
|
+
environment,
|
|
257
|
+
timeoutMs = 30_000,
|
|
258
|
+
spawnProcess = spawn,
|
|
259
|
+
signal,
|
|
260
|
+
} = options;
|
|
188
261
|
if (signal?.aborted) {
|
|
189
262
|
return Promise.resolve({ code: 130, stdout: "", stderr: "", timedOut: false, cancelled: true, exitUnconfirmed: false });
|
|
190
263
|
}
|
|
191
|
-
let child;
|
|
264
|
+
let child: HookChildProcess;
|
|
192
265
|
try {
|
|
193
266
|
child = spawnProcess(command, {
|
|
194
267
|
cwd,
|
|
@@ -279,14 +352,14 @@ export function registerLifecycleHooks(pi: ExtensionAPI): void {
|
|
|
279
352
|
pi.on("tool_call", async (event, ctx) => {
|
|
280
353
|
for (const hook of config.hooks?.tool_call ?? []) {
|
|
281
354
|
if (!matchesHook(hook, event.toolName)) continue;
|
|
282
|
-
const result = await executeHook(
|
|
283
|
-
hook.command,
|
|
284
|
-
ctx.cwd,
|
|
285
|
-
hookEnvironment("tool_call", event.toolName, event.input),
|
|
286
|
-
hook.timeoutMs,
|
|
287
|
-
spawn,
|
|
288
|
-
ctx.signal,
|
|
289
|
-
);
|
|
355
|
+
const result = await executeHook({
|
|
356
|
+
command: hook.command,
|
|
357
|
+
cwd: ctx.cwd,
|
|
358
|
+
environment: hookEnvironment("tool_call", event.toolName, event.input),
|
|
359
|
+
timeoutMs: hook.timeoutMs,
|
|
360
|
+
spawnProcess: spawn,
|
|
361
|
+
signal: ctx.signal,
|
|
362
|
+
});
|
|
290
363
|
if (result.cancelled) return { block: true, reason: "Hook cancelled because the parent request was aborted" };
|
|
291
364
|
if (result.code !== 0) {
|
|
292
365
|
const reason = hookFailureMessage(result);
|
|
@@ -299,17 +372,17 @@ export function registerLifecycleHooks(pi: ExtensionAPI): void {
|
|
|
299
372
|
pi.on("tool_result", async (event, ctx) => {
|
|
300
373
|
for (const hook of config.hooks?.tool_result ?? []) {
|
|
301
374
|
if (!matchesHook(hook, event.toolName)) continue;
|
|
302
|
-
const result = await executeHook(
|
|
303
|
-
hook.command,
|
|
304
|
-
ctx.cwd,
|
|
305
|
-
hookEnvironment("tool_result", event.toolName, {
|
|
375
|
+
const result = await executeHook({
|
|
376
|
+
command: hook.command,
|
|
377
|
+
cwd: ctx.cwd,
|
|
378
|
+
environment: hookEnvironment("tool_result", event.toolName, {
|
|
306
379
|
input: event.input,
|
|
307
380
|
isError: event.isError,
|
|
308
381
|
}),
|
|
309
|
-
hook.timeoutMs,
|
|
310
|
-
spawn,
|
|
311
|
-
ctx.signal,
|
|
312
|
-
);
|
|
382
|
+
timeoutMs: hook.timeoutMs,
|
|
383
|
+
spawnProcess: spawn,
|
|
384
|
+
signal: ctx.signal,
|
|
385
|
+
});
|
|
313
386
|
if (result.cancelled) break;
|
|
314
387
|
if (result.code !== 0) ctx.ui.notify(hookFailureMessage(result), "error");
|
|
315
388
|
}
|
|
@@ -317,14 +390,14 @@ export function registerLifecycleHooks(pi: ExtensionAPI): void {
|
|
|
317
390
|
|
|
318
391
|
pi.on("agent_settled", async (_event, ctx) => {
|
|
319
392
|
for (const hook of config.hooks?.agent_settled ?? []) {
|
|
320
|
-
const result = await executeHook(
|
|
321
|
-
hook.command,
|
|
322
|
-
ctx.cwd,
|
|
323
|
-
hookEnvironment("agent_settled"),
|
|
324
|
-
hook.timeoutMs,
|
|
325
|
-
spawn,
|
|
326
|
-
ctx.signal,
|
|
327
|
-
);
|
|
393
|
+
const result = await executeHook({
|
|
394
|
+
command: hook.command,
|
|
395
|
+
cwd: ctx.cwd,
|
|
396
|
+
environment: hookEnvironment("agent_settled"),
|
|
397
|
+
timeoutMs: hook.timeoutMs,
|
|
398
|
+
spawnProcess: spawn,
|
|
399
|
+
signal: ctx.signal,
|
|
400
|
+
});
|
|
328
401
|
if (result.cancelled) break;
|
|
329
402
|
if (result.code !== 0) ctx.ui.notify(hookFailureMessage(result), "error");
|
|
330
403
|
}
|
|
@@ -60,7 +60,8 @@ async function collectCandidates(projectRoot: string): Promise<string[]> {
|
|
|
60
60
|
const queue: Array<{ relativePath: string; depth: number }> = [{ relativePath: "", depth: 0 }];
|
|
61
61
|
let directoriesRead = 0;
|
|
62
62
|
while (queue.length && files.length < PATH_LIMIT && directoriesRead < DIRECTORY_LIMIT) {
|
|
63
|
-
const current = queue.shift()
|
|
63
|
+
const current = queue.shift();
|
|
64
|
+
if (!current) break;
|
|
64
65
|
directoriesRead += 1;
|
|
65
66
|
let entries;
|
|
66
67
|
try {
|
|
@@ -270,7 +271,8 @@ export function listInitEvidence(index: InitEvidenceIndex, requestedPath = "."):
|
|
|
270
271
|
const remainder = relativePath.slice(prefixWithSlash.length);
|
|
271
272
|
if (!remainder) continue;
|
|
272
273
|
found = true;
|
|
273
|
-
|
|
274
|
+
const child = remainder.split("/", 1)[0];
|
|
275
|
+
if (child) children.add(child);
|
|
274
276
|
}
|
|
275
277
|
if (!found) throw new Error(`${requestedPath} is not available to /init`);
|
|
276
278
|
return [...children].sort((left, right) => left.localeCompare(right)).slice(0, PATH_LIMIT);
|
package/killeros/init-target.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { constants } from "node:fs";
|
|
|
3
3
|
import { promises as fs } from "node:fs";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { hasErrorCode } from "./errors.ts";
|
|
6
7
|
|
|
7
8
|
const TARGET_LIMIT = 128 * 1024;
|
|
8
9
|
const REQUIRED_GUIDANCE_HEADINGS = [
|
|
@@ -13,15 +14,17 @@ const REQUIRED_GUIDANCE_HEADINGS = [
|
|
|
13
14
|
"## 4. Goal-Driven Execution",
|
|
14
15
|
] as const;
|
|
15
16
|
|
|
16
|
-
export
|
|
17
|
-
exists:
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
17
|
+
export type InitTargetBaseline =
|
|
18
|
+
| { exists: false }
|
|
19
|
+
| {
|
|
20
|
+
exists: true;
|
|
21
|
+
content: string;
|
|
22
|
+
digest: string;
|
|
23
|
+
dev: number;
|
|
24
|
+
ino: number;
|
|
25
|
+
mode: number;
|
|
26
|
+
nlink: number;
|
|
27
|
+
};
|
|
25
28
|
|
|
26
29
|
export interface InitInstallOperations {
|
|
27
30
|
renameFile?: typeof fs.rename;
|
|
@@ -33,7 +36,7 @@ function digest(content: Buffer): string {
|
|
|
33
36
|
return createHash("sha256").update(content).digest("hex");
|
|
34
37
|
}
|
|
35
38
|
|
|
36
|
-
async function captureExistingTarget(targetPath: string): Promise<InitTargetBaseline
|
|
39
|
+
async function captureExistingTarget(targetPath: string): Promise<Extract<InitTargetBaseline, { exists: true }>> {
|
|
37
40
|
const pathStat = await fs.lstat(targetPath);
|
|
38
41
|
if (pathStat.isSymbolicLink() || !pathStat.isFile() || pathStat.nlink !== 1) {
|
|
39
42
|
throw new Error("/init requires root AGENTS.md to be absent or a regular, non-linked file");
|
|
@@ -67,14 +70,14 @@ export async function captureInitTargetBaseline(targetPath: string): Promise<Ini
|
|
|
67
70
|
try {
|
|
68
71
|
return await captureExistingTarget(targetPath);
|
|
69
72
|
} catch (error) {
|
|
70
|
-
if ((error
|
|
73
|
+
if (hasErrorCode(error, "ENOENT")) return { exists: false };
|
|
71
74
|
throw error;
|
|
72
75
|
}
|
|
73
76
|
}
|
|
74
77
|
|
|
75
78
|
function sameBaseline(left: InitTargetBaseline, right: InitTargetBaseline): boolean {
|
|
76
|
-
if (left.exists
|
|
77
|
-
if (!
|
|
79
|
+
if (!left.exists) return !right.exists;
|
|
80
|
+
if (!right.exists) return false;
|
|
78
81
|
return left.digest === right.digest
|
|
79
82
|
&& left.dev === right.dev
|
|
80
83
|
&& left.ino === right.ino
|
|
@@ -82,7 +85,10 @@ function sameBaseline(left: InitTargetBaseline, right: InitTargetBaseline): bool
|
|
|
82
85
|
&& left.nlink === right.nlink;
|
|
83
86
|
}
|
|
84
87
|
|
|
85
|
-
async function installedCandidateMatches(
|
|
88
|
+
async function installedCandidateMatches(
|
|
89
|
+
targetPath: string,
|
|
90
|
+
candidate: Extract<InitTargetBaseline, { exists: true }>,
|
|
91
|
+
): Promise<boolean> {
|
|
86
92
|
try {
|
|
87
93
|
const pathStat = await fs.lstat(targetPath);
|
|
88
94
|
if (pathStat.isSymbolicLink() || !pathStat.isFile() || pathStat.nlink !== 2) return false;
|
|
@@ -102,7 +108,7 @@ async function installedCandidateMatches(targetPath: string, candidate: InitTarg
|
|
|
102
108
|
await handle.close();
|
|
103
109
|
}
|
|
104
110
|
} catch (error) {
|
|
105
|
-
if ((error
|
|
111
|
+
if (hasErrorCode(error, "ENOENT")) return false;
|
|
106
112
|
throw error;
|
|
107
113
|
}
|
|
108
114
|
}
|
|
@@ -124,7 +130,9 @@ export function validateGeneratedGuidance(content: string): string | undefined {
|
|
|
124
130
|
for (const heading of REQUIRED_GUIDANCE_HEADINGS) {
|
|
125
131
|
const matches = [...content.matchAll(new RegExp(`^${heading.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&")}$`, "gmu"))];
|
|
126
132
|
if (matches.length !== 1) return `generated guidance must contain ${heading} exactly once`;
|
|
127
|
-
const
|
|
133
|
+
const match = matches[0];
|
|
134
|
+
if (!match) return `generated guidance must contain ${heading} exactly once`;
|
|
135
|
+
const index = match.index;
|
|
128
136
|
if (index <= previous) return "generated guidance headings must occur in the required order";
|
|
129
137
|
previous = index;
|
|
130
138
|
}
|
|
@@ -141,7 +149,7 @@ async function pathExists(filePath: string): Promise<boolean> {
|
|
|
141
149
|
await fs.lstat(filePath);
|
|
142
150
|
return true;
|
|
143
151
|
} catch (error) {
|
|
144
|
-
if ((error
|
|
152
|
+
if (hasErrorCode(error, "ENOENT")) return false;
|
|
145
153
|
throw error;
|
|
146
154
|
}
|
|
147
155
|
}
|
|
@@ -170,7 +178,7 @@ export async function installInitAgentsFile(
|
|
|
170
178
|
const heldPath = path.join(tempDirectory, "held.md");
|
|
171
179
|
let held = false;
|
|
172
180
|
let installed = false;
|
|
173
|
-
let candidate: InitTargetBaseline | undefined;
|
|
181
|
+
let candidate: Extract<InitTargetBaseline, { exists: true }> | undefined;
|
|
174
182
|
let retainedRecovery: string | undefined;
|
|
175
183
|
try {
|
|
176
184
|
const handle = await fs.open(candidatePath, "wx", 0o600);
|
|
@@ -187,7 +195,7 @@ export async function installInitAgentsFile(
|
|
|
187
195
|
await linkFile(candidatePath, targetPath);
|
|
188
196
|
installed = true;
|
|
189
197
|
} catch (error) {
|
|
190
|
-
if ((error
|
|
198
|
+
if (hasErrorCode(error, "EEXIST")) {
|
|
191
199
|
throw new Error("/init target changed while /init was generating; the newer AGENTS.md was preserved");
|
|
192
200
|
}
|
|
193
201
|
throw error;
|
|
@@ -221,7 +229,7 @@ export async function installInitAgentsFile(
|
|
|
221
229
|
await linkFile(candidatePath, targetPath);
|
|
222
230
|
installed = true;
|
|
223
231
|
} catch (error) {
|
|
224
|
-
if ((error
|
|
232
|
+
if (hasErrorCode(error, "EEXIST")) {
|
|
225
233
|
retainedRecovery = recoveryPath(targetPath);
|
|
226
234
|
await renameFile(heldPath, retainedRecovery);
|
|
227
235
|
held = false;
|
package/killeros/init.ts
CHANGED
|
@@ -22,6 +22,7 @@ import { safeTerminalText } from "./safe-terminal-text.ts";
|
|
|
22
22
|
const INIT_WRITE_TOOL = "killeros_init_write";
|
|
23
23
|
const INIT_CONFLICT_TOOL = "killeros_init_conflict";
|
|
24
24
|
const INIT_SCOPED_TOOLS = [INIT_READ_TOOL, INIT_LIST_TOOL, INIT_WRITE_TOOL, INIT_CONFLICT_TOOL] as const;
|
|
25
|
+
const INIT_SCOPED_TOOL_NAMES: ReadonlySet<string> = new Set(INIT_SCOPED_TOOLS);
|
|
25
26
|
const INIT_GENERATED_CONTENT_LIMIT = 128 * 1024;
|
|
26
27
|
|
|
27
28
|
export const INIT_WORKFLOW_PROMPT = `
|
|
@@ -49,13 +50,13 @@ After a successful write, read generated AGENTS.md once through killeros_init_re
|
|
|
49
50
|
|
|
50
51
|
function setInitTools(pi: ExtensionAPI, initState: InitRuntime, active: boolean): void {
|
|
51
52
|
if (active) {
|
|
52
|
-
initState.activeTools ??= pi.getActiveTools().filter((name) => !
|
|
53
|
+
initState.activeTools ??= pi.getActiveTools().filter((name) => !INIT_SCOPED_TOOL_NAMES.has(name));
|
|
53
54
|
pi.setActiveTools([...INIT_SCOPED_TOOLS]);
|
|
54
55
|
} else if (initState.activeTools) {
|
|
55
56
|
pi.setActiveTools(initState.activeTools);
|
|
56
57
|
initState.activeTools = undefined;
|
|
57
58
|
} else {
|
|
58
|
-
pi.setActiveTools(pi.getActiveTools().filter((name) => !
|
|
59
|
+
pi.setActiveTools(pi.getActiveTools().filter((name) => !INIT_SCOPED_TOOL_NAMES.has(name)));
|
|
59
60
|
}
|
|
60
61
|
}
|
|
61
62
|
|
|
@@ -143,7 +144,7 @@ export function registerInitCommand(pi: ExtensionAPI, initState: InitRuntime, go
|
|
|
143
144
|
});
|
|
144
145
|
pi.on("tool_call", (event) => {
|
|
145
146
|
if (!initState.active) return;
|
|
146
|
-
if (!
|
|
147
|
+
if (!INIT_SCOPED_TOOL_NAMES.has(event.toolName)) {
|
|
147
148
|
return { block: true, reason: "/init may use only its bounded evidence and terminal tools" };
|
|
148
149
|
}
|
|
149
150
|
if ((event.toolName === INIT_WRITE_TOOL || event.toolName === INIT_CONFLICT_TOOL) && initState.outcome.kind !== "pending") {
|
|
@@ -229,7 +230,7 @@ export function registerInitCommand(pi: ExtensionAPI, initState: InitRuntime, go
|
|
|
229
230
|
JSON.stringify(initState.evidence.snapshot),
|
|
230
231
|
"",
|
|
231
232
|
"## Existing root AGENTS.md (protected policy; not untrusted evidence)",
|
|
232
|
-
JSON.stringify(initState.baseline.content
|
|
233
|
+
JSON.stringify(initState.baseline.exists ? initState.baseline.content : null),
|
|
233
234
|
].join("\n"),
|
|
234
235
|
display: false,
|
|
235
236
|
}, { triggerTurn: true });
|
|
@@ -255,8 +256,14 @@ export function registerInitCommand(pi: ExtensionAPI, initState: InitRuntime, go
|
|
|
255
256
|
break;
|
|
256
257
|
case "cancelled":
|
|
257
258
|
break;
|
|
258
|
-
|
|
259
|
+
case "pending":
|
|
260
|
+
case "no-outcome":
|
|
259
261
|
reportError(ctx, "/init did not generate AGENTS.md", "the model completed without a write or policy-conflict outcome");
|
|
262
|
+
break;
|
|
263
|
+
default: {
|
|
264
|
+
const exhaustive: never = outcome;
|
|
265
|
+
return exhaustive;
|
|
266
|
+
}
|
|
260
267
|
}
|
|
261
268
|
},
|
|
262
269
|
});
|
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
import { closeSync,
|
|
1
|
+
import { closeSync, openSync, readSync } from "node:fs";
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { StringDecoder } from "node:string_decoder";
|
|
5
|
-
import {
|
|
6
|
-
import { CONFIG_DIR_NAME, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
7
6
|
import type { InitRuntime } from "./runtime.ts";
|
|
8
7
|
|
|
9
8
|
const PERSONAL_INSTRUCTIONS_FILE = "AGENTS.local.md";
|
|
@@ -42,7 +41,7 @@ export function resolvePersonalInstructions(cwd: string): string | undefined {
|
|
|
42
41
|
const importMatch = local.trim().match(/^@(.+)$/u);
|
|
43
42
|
let content = local;
|
|
44
43
|
if (importMatch) {
|
|
45
|
-
const requestedPath = importMatch[1]
|
|
44
|
+
const requestedPath = (importMatch[1] ?? "").trim();
|
|
46
45
|
const importedPath = requestedPath.startsWith("~/") || requestedPath.startsWith("~\\")
|
|
47
46
|
? path.join(os.homedir(), requestedPath.slice(2))
|
|
48
47
|
: path.resolve(cwd, requestedPath);
|
package/killeros/question.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
2
2
|
import {
|
|
3
3
|
type ExtensionAPI,
|
|
4
|
-
type ExtensionContext,
|
|
5
4
|
type ThemeColor,
|
|
6
5
|
type ToolDefinition,
|
|
7
6
|
} from "@earendil-works/pi-coding-agent";
|
|
@@ -165,7 +164,9 @@ function boundedQuestionLines(question: string, width: number, rowLimit: number)
|
|
|
165
164
|
const wrapped = wrapTextWithAnsi(question.replace(/\s+/gu, " ").trim(), width);
|
|
166
165
|
if (wrapped.length <= rowLimit) return wrapped;
|
|
167
166
|
const visible = wrapped.slice(0, rowLimit);
|
|
168
|
-
|
|
167
|
+
const finalIndex = rowLimit - 1;
|
|
168
|
+
const finalLine = visible[finalIndex];
|
|
169
|
+
if (finalLine !== undefined) visible[finalIndex] = truncateToWidth(finalLine, width, "…");
|
|
169
170
|
return visible;
|
|
170
171
|
}
|
|
171
172
|
|
|
@@ -173,12 +174,12 @@ function compactMultipleAnswers(answers: readonly string[], width: number): stri
|
|
|
173
174
|
const prefix = "✓ ";
|
|
174
175
|
if (answers.length === 0) return truncateToWidth(prefix + "No answers", width, "…");
|
|
175
176
|
const visible: string[] = [];
|
|
176
|
-
for (
|
|
177
|
+
for (const [index, answer] of answers.entries()) {
|
|
177
178
|
const remaining = answers.length - index - 1;
|
|
178
|
-
const candidate = [...visible, oneLine(
|
|
179
|
+
const candidate = [...visible, oneLine(answer)].join(", ");
|
|
179
180
|
const suffix = remaining > 0 ? `, +${remaining} more` : "";
|
|
180
181
|
if (visibleWidth(prefix + candidate + suffix) > width) break;
|
|
181
|
-
visible.push(oneLine(
|
|
182
|
+
visible.push(oneLine(answer));
|
|
182
183
|
}
|
|
183
184
|
if (visible.length === answers.length) return prefix + visible.join(", ");
|
|
184
185
|
const hidden = answers.length - visible.length;
|
|
@@ -228,7 +229,8 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
|
|
|
228
229
|
if (bytes > CUSTOM_INPUT_HISTORY_BYTES) return false;
|
|
229
230
|
const existingIndex = customInputHistory.indexOf(value);
|
|
230
231
|
if (existingIndex >= 0) {
|
|
231
|
-
|
|
232
|
+
const existing = customInputHistory[existingIndex];
|
|
233
|
+
if (existing !== undefined) customInputHistoryBytes -= Buffer.byteLength(existing, "utf8");
|
|
232
234
|
customInputHistory.splice(existingIndex, 1);
|
|
233
235
|
}
|
|
234
236
|
while (customInputHistory.length >= CUSTOM_INPUT_HISTORY_LIMIT || customInputHistoryBytes + bytes > CUSTOM_INPUT_HISTORY_BYTES) {
|
|
@@ -329,7 +331,11 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
|
|
|
329
331
|
const selectedCount = (): number => selectedOriginalIndices.size + (customAnswer === undefined ? 0 : 1);
|
|
330
332
|
const orderedMultipleSelection = () => {
|
|
331
333
|
const selectedIndices = [...selectedOriginalIndices].sort((left, right) => left - right);
|
|
332
|
-
const predefined = selectedIndices.map((index) =>
|
|
334
|
+
const predefined = selectedIndices.map((index) => {
|
|
335
|
+
const option = params.options[index - 1];
|
|
336
|
+
if (!option) throw new Error("Question selection no longer matches an available option");
|
|
337
|
+
return option.label;
|
|
338
|
+
});
|
|
333
339
|
return {
|
|
334
340
|
answers: customAnswer === undefined ? predefined : [...predefined, customAnswer],
|
|
335
341
|
selectedIndices,
|
|
@@ -640,7 +646,8 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
|
|
|
640
646
|
lines.push(...(draftLines.length > 0 ? draftLines : [editMode === "filter" ? "Type a filter" : "Type an answer"]).slice(-contentRows));
|
|
641
647
|
} else {
|
|
642
648
|
for (let index = start; index < end; index += 1) {
|
|
643
|
-
const option = visibleOptions[index]
|
|
649
|
+
const option = visibleOptions[index];
|
|
650
|
+
if (!option) continue;
|
|
644
651
|
const color: ThemeColor = index === optionIndex ? "accent" : "text";
|
|
645
652
|
lines.push(theme.fg(color, truncateToWidth(optionLabel(option, index), width, "…")));
|
|
646
653
|
}
|