killeros 2.0.21 → 2.1.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +35 -0
- package/Killeros.ts +5 -2
- package/README.md +25 -3
- package/killeros/auto-compaction.ts +56 -15
- package/killeros/change-receipt.ts +727 -0
- package/killeros/commands.ts +1 -1
- package/killeros/footer.ts +157 -32
- package/killeros/goal-command.ts +120 -0
- package/killeros/goal-history.ts +71 -0
- package/killeros/goal-interface.ts +599 -0
- package/killeros/goal-runtime.ts +349 -0
- package/killeros/goal-settlement.ts +261 -0
- package/killeros/goal-state.ts +95 -13
- package/killeros/hooks.ts +144 -20
- package/killeros/personal-instructions.ts +5 -0
- package/killeros/runtime.ts +9 -0
- package/killeros/worked-for.ts +271 -61
- package/package.json +3 -2
- package/killeros/goals.ts +0 -1022
package/killeros/goal-state.ts
CHANGED
|
@@ -2,9 +2,12 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import type { Stats } from "node:fs";
|
|
3
3
|
import { lstat, open, type FileHandle } from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
|
-
import type { GoalBlockerAudit, GoalFileBaseline, GoalFileVerification, GoalState, GoalStateCommon, GoalStatus } from "./runtime.ts";
|
|
5
|
+
import type { GoalBlockerAudit, GoalCompletionCheck, GoalFileBaseline, GoalFileVerification, GoalState, GoalStateCommon, GoalStatus } from "./runtime.ts";
|
|
6
6
|
|
|
7
|
+
export const DEFAULT_GOAL_MAX_TURNS = 20;
|
|
7
8
|
export const GOAL_OBJECTIVE_LIMIT = 4_000;
|
|
9
|
+
export const GOAL_MAX_TURNS = 10_000;
|
|
10
|
+
export const GOAL_CHECK_NAME_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/u;
|
|
8
11
|
export const GOAL_VERSION = 1;
|
|
9
12
|
const FILE_HASH_CHUNK_SIZE = 64 * 1024;
|
|
10
13
|
export const FILE_HASH_LIMIT = 64 * 1024 * 1024;
|
|
@@ -25,6 +28,20 @@ function finiteNonNegative(value: unknown): value is number {
|
|
|
25
28
|
return typeof value === "number" && Number.isFinite(value) && value >= 0;
|
|
26
29
|
}
|
|
27
30
|
|
|
31
|
+
function safeNonNegativeInteger(value: unknown): value is number {
|
|
32
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function incrementableNonNegativeInteger(value: unknown): value is number {
|
|
36
|
+
return safeNonNegativeInteger(value) && value < Number.MAX_SAFE_INTEGER;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function addGoalMilliseconds(accumulated: number, interval: number): number {
|
|
40
|
+
const total = accumulated + interval;
|
|
41
|
+
if (!safeNonNegativeInteger(total)) throw new Error("Goal active duration exceeds the safe integer range");
|
|
42
|
+
return total;
|
|
43
|
+
}
|
|
44
|
+
|
|
28
45
|
function isUnknownRecord(value: unknown): value is Record<string, unknown> {
|
|
29
46
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
30
47
|
}
|
|
@@ -56,12 +73,27 @@ function isGoalFileVerification(value: unknown): value is GoalFileVerification {
|
|
|
56
73
|
&& isGoalFileBaseline(value.baseline);
|
|
57
74
|
}
|
|
58
75
|
|
|
76
|
+
function isGoalCompletionCheck(value: unknown): value is GoalCompletionCheck {
|
|
77
|
+
return isUnknownRecord(value)
|
|
78
|
+
&& value.kind === "named-command"
|
|
79
|
+
&& typeof value.name === "string"
|
|
80
|
+
&& GOAL_CHECK_NAME_PATTERN.test(value.name)
|
|
81
|
+
&& typeof value.configHash === "string"
|
|
82
|
+
&& /^[a-f0-9]{64}$/u.test(value.configHash);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function isMaxTurns(value: unknown): value is number {
|
|
86
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= GOAL_MAX_TURNS;
|
|
87
|
+
}
|
|
88
|
+
|
|
59
89
|
function isGoalBlockerAudit(value: unknown, turns: number, status: GoalStatus): value is GoalBlockerAudit {
|
|
60
90
|
if (!isUnknownRecord(value)
|
|
61
91
|
|| typeof value.key !== "string"
|
|
62
92
|
|| !/^[a-z0-9][a-z0-9._-]{0,119}$/u.test(value.key)
|
|
63
93
|
|| typeof value.streak !== "number" || !Number.isInteger(value.streak) || value.streak < 1 || value.streak > 3
|
|
64
|
-
|| typeof value.lastTurn !== "number" || !Number.isInteger(value.lastTurn) || value.lastTurn < 1 || value.lastTurn > turns
|
|
94
|
+
|| typeof value.lastTurn !== "number" || !Number.isInteger(value.lastTurn) || value.lastTurn < 1 || value.lastTurn > turns
|
|
95
|
+
|| value.evidence !== undefined && (typeof value.evidence !== "string"
|
|
96
|
+
|| value.evidence !== value.evidence.trim() || !value.evidence || value.evidence.length > 2_000)) {
|
|
65
97
|
return false;
|
|
66
98
|
}
|
|
67
99
|
if (status === "complete") return false;
|
|
@@ -86,21 +118,24 @@ export function parseGoalState(value: unknown): GoalState | undefined {
|
|
|
86
118
|
resumeAfterManualCompaction,
|
|
87
119
|
blockerAudit,
|
|
88
120
|
verification,
|
|
121
|
+
completionCheck,
|
|
122
|
+
maxTurns,
|
|
89
123
|
} = value;
|
|
90
124
|
if (version !== GOAL_VERSION
|
|
91
|
-
||
|
|
125
|
+
|| !incrementableNonNegativeInteger(revision) || revision < 1
|
|
92
126
|
|| typeof objective !== "string" || !objective.trim() || [...objective].length > GOAL_OBJECTIVE_LIMIT
|
|
93
127
|
|| !isGoalStatus(status)
|
|
94
|
-
|| !
|
|
95
|
-
|| !
|
|
96
|
-
|| !
|
|
97
|
-
||
|
|
128
|
+
|| !safeNonNegativeInteger(createdAt)
|
|
129
|
+
|| !safeNonNegativeInteger(updatedAt)
|
|
130
|
+
|| !incrementableNonNegativeInteger(activeMilliseconds)
|
|
131
|
+
|| !incrementableNonNegativeInteger(turns)
|
|
98
132
|
|| blockedAuditStartTurn !== undefined
|
|
99
|
-
&& (
|
|
100
|
-
|
|
101
|
-
|| !finiteNonNegative(baselineTokens)
|
|
133
|
+
&& (!safeNonNegativeInteger(blockedAuditStartTurn) || blockedAuditStartTurn > turns)
|
|
134
|
+
|| !safeNonNegativeInteger(baselineTokens)
|
|
102
135
|
|| result !== undefined && typeof result !== "string"
|
|
103
136
|
|| verification !== undefined && !isGoalFileVerification(verification)
|
|
137
|
+
|| completionCheck !== undefined && !isGoalCompletionCheck(completionCheck)
|
|
138
|
+
|| maxTurns !== undefined && !isMaxTurns(maxTurns)
|
|
104
139
|
|| resumeAfterManualCompaction !== undefined && resumeAfterManualCompaction !== true
|
|
105
140
|
|| blockerAudit !== undefined && !isGoalBlockerAudit(blockerAudit, turns, status)) {
|
|
106
141
|
return undefined;
|
|
@@ -117,10 +152,12 @@ export function parseGoalState(value: unknown): GoalState | undefined {
|
|
|
117
152
|
blockedAuditStartTurn: blockedAuditStartTurn ?? 0,
|
|
118
153
|
baselineTokens,
|
|
119
154
|
...(verification === undefined ? {} : { verification }),
|
|
155
|
+
...(completionCheck === undefined ? {} : { completionCheck }),
|
|
156
|
+
...(maxTurns === undefined ? {} : { maxTurns }),
|
|
120
157
|
};
|
|
121
158
|
switch (status) {
|
|
122
159
|
case "active":
|
|
123
|
-
if (!
|
|
160
|
+
if (!safeNonNegativeInteger(activeStartedAt) || resumeAfterManualCompaction !== undefined) return undefined;
|
|
124
161
|
return {
|
|
125
162
|
...common,
|
|
126
163
|
status,
|
|
@@ -250,7 +287,7 @@ export function validateGoalObjective(input: string): string | undefined {
|
|
|
250
287
|
|
|
251
288
|
export function goalElapsedMilliseconds(state: GoalState, now: number): number {
|
|
252
289
|
const activeInterval = state.status === "active" ? Math.max(0, now - state.activeStartedAt) : 0;
|
|
253
|
-
return state.activeMilliseconds
|
|
290
|
+
return addGoalMilliseconds(state.activeMilliseconds, activeInterval);
|
|
254
291
|
}
|
|
255
292
|
|
|
256
293
|
export function commonGoalState(state: GoalState): GoalStateCommon {
|
|
@@ -265,13 +302,15 @@ export function commonGoalState(state: GoalState): GoalStateCommon {
|
|
|
265
302
|
blockedAuditStartTurn: state.blockedAuditStartTurn,
|
|
266
303
|
baselineTokens: state.baselineTokens,
|
|
267
304
|
...(state.verification === undefined ? {} : { verification: state.verification }),
|
|
305
|
+
...(state.completionCheck === undefined ? {} : { completionCheck: state.completionCheck }),
|
|
306
|
+
...(state.maxTurns === undefined ? {} : { maxTurns: state.maxTurns }),
|
|
268
307
|
};
|
|
269
308
|
}
|
|
270
309
|
|
|
271
310
|
export function stopGoalClock(state: GoalState, now: number): GoalStateCommon {
|
|
272
311
|
const common = commonGoalState(state);
|
|
273
312
|
return state.status === "active"
|
|
274
|
-
? { ...common, activeMilliseconds: common.activeMilliseconds
|
|
313
|
+
? { ...common, activeMilliseconds: addGoalMilliseconds(common.activeMilliseconds, Math.max(0, now - state.activeStartedAt)) }
|
|
275
314
|
: common;
|
|
276
315
|
}
|
|
277
316
|
|
|
@@ -280,6 +319,7 @@ export function createNewGoalState(
|
|
|
280
319
|
baselineTokens: number,
|
|
281
320
|
verification: GoalFileVerification | undefined,
|
|
282
321
|
now: number,
|
|
322
|
+
controls: { completionCheck?: GoalCompletionCheck; maxTurns?: number } = {},
|
|
283
323
|
): GoalState {
|
|
284
324
|
return {
|
|
285
325
|
version: GOAL_VERSION,
|
|
@@ -294,6 +334,8 @@ export function createNewGoalState(
|
|
|
294
334
|
blockedAuditStartTurn: 0,
|
|
295
335
|
baselineTokens,
|
|
296
336
|
...(verification === undefined ? {} : { verification }),
|
|
337
|
+
...(controls.completionCheck === undefined ? {} : { completionCheck: controls.completionCheck }),
|
|
338
|
+
...(controls.maxTurns === undefined ? {} : { maxTurns: controls.maxTurns }),
|
|
297
339
|
};
|
|
298
340
|
}
|
|
299
341
|
|
|
@@ -317,6 +359,46 @@ export function editGoalState(
|
|
|
317
359
|
};
|
|
318
360
|
}
|
|
319
361
|
|
|
362
|
+
export function updateGoalControlsState(
|
|
363
|
+
state: Exclude<GoalState, { status: "complete" }>,
|
|
364
|
+
controls: { completionCheck?: GoalCompletionCheck; maxTurns?: number },
|
|
365
|
+
now: number,
|
|
366
|
+
): GoalState {
|
|
367
|
+
const stopped = stopGoalClock(state, now);
|
|
368
|
+
const { completionCheck: _completionCheck, maxTurns: _maxTurns, ...common } = stopped;
|
|
369
|
+
const nextCommon: GoalStateCommon = {
|
|
370
|
+
...common,
|
|
371
|
+
revision: common.revision + 1,
|
|
372
|
+
updatedAt: now,
|
|
373
|
+
...(controls.completionCheck === undefined ? {} : { completionCheck: controls.completionCheck }),
|
|
374
|
+
...(controls.maxTurns === undefined ? {} : { maxTurns: controls.maxTurns }),
|
|
375
|
+
};
|
|
376
|
+
if (state.status === "active") {
|
|
377
|
+
return {
|
|
378
|
+
...nextCommon,
|
|
379
|
+
status: "active",
|
|
380
|
+
activeStartedAt: now,
|
|
381
|
+
...(state.result === undefined ? {} : { result: state.result }),
|
|
382
|
+
...(state.blockerAudit === undefined ? {} : { blockerAudit: state.blockerAudit }),
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
if (state.status === "paused") {
|
|
386
|
+
return {
|
|
387
|
+
...nextCommon,
|
|
388
|
+
status: "paused",
|
|
389
|
+
...(state.result === undefined ? {} : { result: state.result }),
|
|
390
|
+
...(state.blockerAudit === undefined ? {} : { blockerAudit: state.blockerAudit }),
|
|
391
|
+
...(state.resumeAfterManualCompaction === undefined ? {} : { resumeAfterManualCompaction: true }),
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
return {
|
|
395
|
+
...nextCommon,
|
|
396
|
+
status: "blocked",
|
|
397
|
+
result: state.result,
|
|
398
|
+
...(state.blockerAudit === undefined ? {} : { blockerAudit: state.blockerAudit }),
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
|
|
320
402
|
export function beginGoalTurnState(
|
|
321
403
|
current: Extract<GoalState, { status: "active" }>,
|
|
322
404
|
now: number,
|
package/killeros/hooks.ts
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import {
|
|
2
3
|
spawn,
|
|
3
4
|
type SpawnOptionsWithStdioTuple,
|
|
4
5
|
} from "node:child_process";
|
|
6
|
+
import { EventEmitter } from "node:events";
|
|
5
7
|
import { closeSync, existsSync, fstatSync, lstatSync, openSync, readSync, realpathSync } from "node:fs";
|
|
6
8
|
import path from "node:path";
|
|
7
9
|
import { StringDecoder } from "node:string_decoder";
|
|
8
10
|
import { CONFIG_DIR_NAME, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
9
11
|
import { errorMessage, reportError } from "./errors.ts";
|
|
12
|
+
import { GOAL_CHECK_NAME_PATTERN } from "./goal-state.ts";
|
|
13
|
+
import type { GoalCompletionCheck } from "./runtime.ts";
|
|
10
14
|
import { safeTerminalText } from "./safe-terminal-text.ts";
|
|
11
15
|
|
|
12
16
|
type KillerosHookEvent = "tool_call" | "tool_result" | "agent_settled";
|
|
@@ -17,8 +21,14 @@ interface KillerosHook {
|
|
|
17
21
|
timeoutMs?: number;
|
|
18
22
|
}
|
|
19
23
|
|
|
24
|
+
interface KillerosGoalCheck {
|
|
25
|
+
command: string;
|
|
26
|
+
timeoutMs?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
20
29
|
interface KillerosHookConfig {
|
|
21
30
|
hooks?: Partial<Record<KillerosHookEvent, KillerosHook[]>>;
|
|
31
|
+
goalChecks?: Record<string, KillerosGoalCheck>;
|
|
22
32
|
}
|
|
23
33
|
|
|
24
34
|
interface HookExecutionResult {
|
|
@@ -58,7 +68,10 @@ const HOOK_EVENTS: readonly KillerosHookEvent[] = ["tool_call", "tool_result", "
|
|
|
58
68
|
const HOOK_CONFIG_LIMIT = 64 * 1024;
|
|
59
69
|
const HOOK_OUTPUT_LIMIT = 16 * 1024;
|
|
60
70
|
const HOOK_PAYLOAD_LIMIT = 8_000;
|
|
71
|
+
const HOOK_TIMEOUT_DEFAULT_MS = 30_000;
|
|
61
72
|
const HOOK_TIMEOUT_MAX_MS = 300_000;
|
|
73
|
+
const GOAL_CHECK_LIMIT = 32;
|
|
74
|
+
const GOAL_CHECK_COMMAND_LIMIT = 8_000;
|
|
62
75
|
|
|
63
76
|
// Reads executable project configuration through a bounded, project-local file descriptor.
|
|
64
77
|
function readHookConfig(configPath: string, projectRoot: string): string {
|
|
@@ -109,11 +122,12 @@ function isUnknownRecord(value: unknown): value is Record<string, unknown> {
|
|
|
109
122
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
110
123
|
}
|
|
111
124
|
|
|
112
|
-
function
|
|
125
|
+
function loadKillerosConfig(ctx: ExtensionContext, strictGoalChecks = false): KillerosHookConfig {
|
|
113
126
|
const configPath = path.join(ctx.cwd, CONFIG_DIR_NAME, "killeros-hooks.json");
|
|
114
127
|
if (!existsSync(configPath)) return {};
|
|
115
128
|
const displayPath = safeTerminalText(configPath).replaceAll("\n", "");
|
|
116
129
|
if (!ctx.isProjectTrusted()) {
|
|
130
|
+
if (strictGoalChecks) throw new Error("Goal completion checks require a trusted project");
|
|
117
131
|
ctx.ui.notify(`Ignored untrusted project hooks in ${displayPath}`, "warning");
|
|
118
132
|
return {};
|
|
119
133
|
}
|
|
@@ -166,8 +180,35 @@ function loadKillerosHooks(ctx: ExtensionContext): KillerosHookConfig {
|
|
|
166
180
|
}
|
|
167
181
|
hooks[event] = accepted;
|
|
168
182
|
}
|
|
169
|
-
|
|
183
|
+
|
|
184
|
+
let goalChecks: Record<string, KillerosGoalCheck> | undefined;
|
|
185
|
+
try {
|
|
186
|
+
const candidates = parsed.goalChecks;
|
|
187
|
+
if (candidates !== undefined) {
|
|
188
|
+
if (!isUnknownRecord(candidates)) throw new Error("goalChecks must contain a JSON object");
|
|
189
|
+
const entries = Object.entries(candidates);
|
|
190
|
+
if (entries.length > GOAL_CHECK_LIMIT) throw new Error(`goalChecks may contain at most ${GOAL_CHECK_LIMIT} checks`);
|
|
191
|
+
goalChecks = {};
|
|
192
|
+
for (const [name, candidate] of entries) {
|
|
193
|
+
if (!GOAL_CHECK_NAME_PATTERN.test(name)) throw new Error(`Invalid goal check name: ${JSON.stringify(name)}`);
|
|
194
|
+
if (!isUnknownRecord(candidate)) throw new Error(`Goal check ${name} must contain a JSON object`);
|
|
195
|
+
const { command, timeoutMs } = candidate;
|
|
196
|
+
if (typeof command !== "string" || command.trim().length < 1 || command.trim().length > GOAL_CHECK_COMMAND_LIMIT) {
|
|
197
|
+
throw new Error(`Goal check ${name} command must contain 1 to ${GOAL_CHECK_COMMAND_LIMIT} characters`);
|
|
198
|
+
}
|
|
199
|
+
if (timeoutMs !== undefined && (typeof timeoutMs !== "number" || !Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > HOOK_TIMEOUT_MAX_MS)) {
|
|
200
|
+
throw new Error(`Goal check ${name} timeoutMs must be an integer from 1 to ${HOOK_TIMEOUT_MAX_MS}`);
|
|
201
|
+
}
|
|
202
|
+
goalChecks[name] = { command: command.trim(), ...(timeoutMs === undefined ? {} : { timeoutMs }) };
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
} catch (error) {
|
|
206
|
+
if (strictGoalChecks) throw error;
|
|
207
|
+
reportError(ctx, `Invalid ${CONFIG_DIR_NAME}/killeros-hooks.json goalChecks`, error);
|
|
208
|
+
}
|
|
209
|
+
return { hooks, ...(goalChecks === undefined ? {} : { goalChecks }) };
|
|
170
210
|
} catch (error) {
|
|
211
|
+
if (strictGoalChecks) throw error;
|
|
171
212
|
reportError(ctx, `Invalid ${CONFIG_DIR_NAME}/killeros-hooks.json`, error);
|
|
172
213
|
return {};
|
|
173
214
|
}
|
|
@@ -197,16 +238,27 @@ function appendBounded(output: HookOutputBuffer, chunk: Buffer | string): void {
|
|
|
197
238
|
output.text += output.decoder.write(captured);
|
|
198
239
|
}
|
|
199
240
|
|
|
241
|
+
/** Terminates a Windows hook tree without depending on the caller's PATH. */
|
|
242
|
+
function terminateWindowsHookTree(child: HookChildProcess): Promise<boolean> {
|
|
243
|
+
return new Promise((resolve) => {
|
|
244
|
+
try {
|
|
245
|
+
const taskkill = process.env.SystemRoot
|
|
246
|
+
? path.join(process.env.SystemRoot, "System32", "taskkill.exe")
|
|
247
|
+
: "taskkill";
|
|
248
|
+
const killer = spawn(taskkill, ["/pid", String(child.pid), "/T", "/F"], {
|
|
249
|
+
shell: false,
|
|
250
|
+
stdio: "ignore",
|
|
251
|
+
windowsHide: true,
|
|
252
|
+
});
|
|
253
|
+
killer.once("error", () => resolve(false));
|
|
254
|
+
killer.once("close", (code) => resolve(code === 0));
|
|
255
|
+
} catch {
|
|
256
|
+
resolve(false);
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
200
261
|
function terminateHookProcess(child: HookChildProcess, force: boolean): void {
|
|
201
|
-
if (process.platform === "win32" && force && child.pid) {
|
|
202
|
-
const killer = spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], {
|
|
203
|
-
shell: false,
|
|
204
|
-
stdio: "ignore",
|
|
205
|
-
windowsHide: true,
|
|
206
|
-
});
|
|
207
|
-
killer.unref();
|
|
208
|
-
return;
|
|
209
|
-
}
|
|
210
262
|
if (process.platform !== "win32" && child.pid) {
|
|
211
263
|
try {
|
|
212
264
|
process.kill(-child.pid, force ? "SIGKILL" : "SIGTERM");
|
|
@@ -254,7 +306,7 @@ export function executeHook(
|
|
|
254
306
|
command,
|
|
255
307
|
cwd,
|
|
256
308
|
environment,
|
|
257
|
-
timeoutMs =
|
|
309
|
+
timeoutMs = HOOK_TIMEOUT_DEFAULT_MS,
|
|
258
310
|
spawnProcess = spawn,
|
|
259
311
|
signal,
|
|
260
312
|
} = options;
|
|
@@ -282,6 +334,7 @@ export function executeHook(
|
|
|
282
334
|
let timer: NodeJS.Timeout | undefined;
|
|
283
335
|
let forceTimer: NodeJS.Timeout | undefined;
|
|
284
336
|
let settleTimer: NodeJS.Timeout | undefined;
|
|
337
|
+
let windowsCleanupPending = false;
|
|
285
338
|
const finish = (code: number, exitUnconfirmed = false): void => {
|
|
286
339
|
if (completed) return;
|
|
287
340
|
completed = true;
|
|
@@ -304,29 +357,58 @@ export function executeHook(
|
|
|
304
357
|
const beginTermination = (reason: "timeout" | "cancelled"): void => {
|
|
305
358
|
if (completed || termination) return;
|
|
306
359
|
termination = reason;
|
|
307
|
-
|
|
360
|
+
if (process.platform === "win32" && child.pid) {
|
|
361
|
+
windowsCleanupPending = true;
|
|
362
|
+
void terminateWindowsHookTree(child).then((confirmed) => {
|
|
363
|
+
if (completed) return;
|
|
364
|
+
if (!confirmed) terminateHookProcess(child, true);
|
|
365
|
+
finish(terminationCode(), !confirmed);
|
|
366
|
+
windowsCleanupPending = false;
|
|
367
|
+
});
|
|
368
|
+
settleTimer = setTimeout(() => {
|
|
369
|
+
terminateHookProcess(child, true);
|
|
370
|
+
finish(terminationCode(), true);
|
|
371
|
+
windowsCleanupPending = false;
|
|
372
|
+
}, 2_000);
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
308
375
|
forceTimer = setTimeout(() => {
|
|
309
376
|
if (completed) return;
|
|
310
377
|
terminateHookProcess(child, true);
|
|
311
378
|
settleTimer = setTimeout(() => finish(terminationCode(), true), 1_000);
|
|
312
379
|
}, 1_000);
|
|
380
|
+
terminateHookProcess(child, false);
|
|
313
381
|
};
|
|
314
382
|
const abort = (): void => beginTermination("cancelled");
|
|
315
|
-
signal?.addEventListener("abort", abort, { once: true });
|
|
316
|
-
if (signal?.aborted) beginTermination("cancelled");
|
|
317
383
|
child.stdout.on("data", (chunk) => appendBounded(stdout, chunk));
|
|
318
384
|
child.stderr.on("data", (chunk) => appendBounded(stderr, chunk));
|
|
385
|
+
const captureStreamError = (error: Error): void => appendBounded(stderr, error.message);
|
|
386
|
+
if (child.stdout instanceof EventEmitter) child.stdout.on("error", captureStreamError);
|
|
387
|
+
if (child.stderr instanceof EventEmitter) child.stderr.on("error", captureStreamError);
|
|
319
388
|
child.on("error", (error) => {
|
|
320
389
|
appendBounded(stderr, error.message);
|
|
321
|
-
finish(termination ? terminationCode() : 1);
|
|
390
|
+
if (!windowsCleanupPending) finish(termination ? terminationCode() : 1);
|
|
391
|
+
});
|
|
392
|
+
child.once("close", (code) => {
|
|
393
|
+
if (!windowsCleanupPending) finish(termination ? terminationCode() : code ?? 1);
|
|
322
394
|
});
|
|
323
|
-
|
|
324
|
-
|
|
395
|
+
if (completed) return;
|
|
396
|
+
const boundedTimeoutMs = Number.isNaN(timeoutMs)
|
|
397
|
+
? HOOK_TIMEOUT_DEFAULT_MS
|
|
398
|
+
: Math.max(1, Math.min(timeoutMs, HOOK_TIMEOUT_MAX_MS));
|
|
399
|
+
timer = setTimeout(() => beginTermination("timeout"), boundedTimeoutMs);
|
|
400
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
401
|
+
if (signal?.aborted) beginTermination("cancelled");
|
|
325
402
|
});
|
|
326
403
|
}
|
|
327
404
|
|
|
328
405
|
function serializeHookPayload(payload: unknown): string {
|
|
329
|
-
|
|
406
|
+
let serialized: string;
|
|
407
|
+
try {
|
|
408
|
+
serialized = JSON.stringify(payload) ?? "null";
|
|
409
|
+
} catch {
|
|
410
|
+
return JSON.stringify({ serializationError: true });
|
|
411
|
+
}
|
|
330
412
|
if (serialized.length <= HOOK_PAYLOAD_LIMIT) return serialized;
|
|
331
413
|
const previewLength = Math.floor((HOOK_PAYLOAD_LIMIT - 64) / 2);
|
|
332
414
|
return JSON.stringify({ truncated: true, preview: serialized.slice(0, previewLength) });
|
|
@@ -345,9 +427,51 @@ function hookFailureMessage(result: HookExecutionResult): string {
|
|
|
345
427
|
return safeTerminalText(`Hook failed${result.timedOut ? " (timed out)" : ""}${result.exitUnconfirmed ? " (process exit unconfirmed)" : ""}\n${detail}`);
|
|
346
428
|
}
|
|
347
429
|
|
|
430
|
+
function goalCheckHash(check: KillerosGoalCheck): string {
|
|
431
|
+
return createHash("sha256")
|
|
432
|
+
.update(JSON.stringify({ command: check.command, timeoutMs: check.timeoutMs ?? HOOK_TIMEOUT_DEFAULT_MS }))
|
|
433
|
+
.digest("hex");
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** Lists validated completion-check names without exposing their definitions. */
|
|
437
|
+
export function listGoalCompletionChecks(ctx: ExtensionContext): readonly string[] {
|
|
438
|
+
if (!ctx.isProjectTrusted()) throw new Error("Goal completion checks require a trusted project");
|
|
439
|
+
return Object.keys(loadKillerosConfig(ctx, true).goalChecks ?? {}).sort();
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
export function resolveGoalCompletionCheck(ctx: ExtensionContext, name: string): GoalCompletionCheck {
|
|
443
|
+
if (!GOAL_CHECK_NAME_PATTERN.test(name)) throw new Error("Invalid goal completion check name");
|
|
444
|
+
if (!ctx.isProjectTrusted()) throw new Error("Goal completion checks require a trusted project");
|
|
445
|
+
const check = loadKillerosConfig(ctx, true).goalChecks?.[name];
|
|
446
|
+
if (!check) throw new Error(`Unknown goal completion check: ${safeTerminalText(name)}`);
|
|
447
|
+
return { kind: "named-command", name, configHash: goalCheckHash(check) };
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
export async function runGoalCompletionCheck(
|
|
451
|
+
ctx: ExtensionContext,
|
|
452
|
+
bound: GoalCompletionCheck,
|
|
453
|
+
signal?: AbortSignal,
|
|
454
|
+
): Promise<void> {
|
|
455
|
+
if (!ctx.isProjectTrusted()) throw new Error("Goal completion checks require a trusted project");
|
|
456
|
+
const check = loadKillerosConfig(ctx, true).goalChecks?.[bound.name];
|
|
457
|
+
if (!check) throw new Error(`Unknown goal completion check: ${safeTerminalText(bound.name)}`);
|
|
458
|
+
if (goalCheckHash(check) !== bound.configHash) {
|
|
459
|
+
throw new Error(`Goal completion check ${safeTerminalText(bound.name)} changed; run /goal check ${safeTerminalText(bound.name)} to approve it`);
|
|
460
|
+
}
|
|
461
|
+
const result = await executeHook({
|
|
462
|
+
command: check.command,
|
|
463
|
+
cwd: ctx.cwd,
|
|
464
|
+
environment: { KILLEROS_EVENT: "goal_check", KILLEROS_GOAL_CHECK: bound.name },
|
|
465
|
+
timeoutMs: check.timeoutMs,
|
|
466
|
+
signal,
|
|
467
|
+
});
|
|
468
|
+
if (result.cancelled) throw new Error(`Goal completion check ${safeTerminalText(bound.name)} was cancelled`);
|
|
469
|
+
if (result.code !== 0) throw new Error(hookFailureMessage(result).replace(/^Hook failed/u, `Goal completion check ${safeTerminalText(bound.name)} failed`));
|
|
470
|
+
}
|
|
471
|
+
|
|
348
472
|
export function registerLifecycleHooks(pi: ExtensionAPI): void {
|
|
349
473
|
let config: KillerosHookConfig = {};
|
|
350
|
-
pi.on("session_start", (_event, ctx) => { config =
|
|
474
|
+
pi.on("session_start", (_event, ctx) => { config = loadKillerosConfig(ctx); });
|
|
351
475
|
|
|
352
476
|
pi.on("tool_call", async (event, ctx) => {
|
|
353
477
|
for (const hook of config.hooks?.tool_call ?? []) {
|
|
@@ -11,7 +11,12 @@ const PERSONAL_INSTRUCTIONS_LIMIT = 32 * 1024;
|
|
|
11
11
|
function readBoundedText(filePath: string, limit = PERSONAL_INSTRUCTIONS_LIMIT): string | undefined {
|
|
12
12
|
let descriptor: number | undefined;
|
|
13
13
|
try {
|
|
14
|
+
const pathStat = lstatSync(filePath);
|
|
15
|
+
if (!pathStat.isFile() || pathStat.isSymbolicLink() || pathStat.nlink !== 1) return undefined;
|
|
14
16
|
descriptor = openSync(filePath, "r");
|
|
17
|
+
const openedStat = fstatSync(descriptor);
|
|
18
|
+
if (!openedStat.isFile() || openedStat.nlink !== 1
|
|
19
|
+
|| openedStat.dev !== pathStat.dev || openedStat.ino !== pathStat.ino) return undefined;
|
|
15
20
|
const buffer = Buffer.alloc(limit + 1);
|
|
16
21
|
const bytesRead = readSync(descriptor, buffer, 0, buffer.length, 0);
|
|
17
22
|
const decoder = new StringDecoder("utf8");
|
package/killeros/runtime.ts
CHANGED
|
@@ -26,6 +26,7 @@ export interface GoalBlockerAudit {
|
|
|
26
26
|
key: string;
|
|
27
27
|
streak: number;
|
|
28
28
|
lastTurn: number;
|
|
29
|
+
evidence?: string;
|
|
29
30
|
}
|
|
30
31
|
|
|
31
32
|
export type GoalFileBaseline =
|
|
@@ -38,6 +39,12 @@ export interface GoalFileVerification {
|
|
|
38
39
|
baseline: GoalFileBaseline;
|
|
39
40
|
}
|
|
40
41
|
|
|
42
|
+
export interface GoalCompletionCheck {
|
|
43
|
+
kind: "named-command";
|
|
44
|
+
name: string;
|
|
45
|
+
configHash: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
41
48
|
export interface GoalStateCommon {
|
|
42
49
|
version: 1;
|
|
43
50
|
revision: number;
|
|
@@ -49,6 +56,8 @@ export interface GoalStateCommon {
|
|
|
49
56
|
blockedAuditStartTurn: number;
|
|
50
57
|
baselineTokens: number;
|
|
51
58
|
verification?: GoalFileVerification;
|
|
59
|
+
completionCheck?: GoalCompletionCheck;
|
|
60
|
+
maxTurns?: number;
|
|
52
61
|
}
|
|
53
62
|
|
|
54
63
|
export type GoalState = GoalStateCommon & (
|