killeros 2.0.22 → 2.1.23

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.
@@ -4,7 +4,9 @@ import { lstat, open, type FileHandle } from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import type { GoalBlockerAudit, 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;
8
10
  export const GOAL_VERSION = 1;
9
11
  const FILE_HASH_CHUNK_SIZE = 64 * 1024;
10
12
  export const FILE_HASH_LIMIT = 64 * 1024 * 1024;
@@ -25,6 +27,20 @@ function finiteNonNegative(value: unknown): value is number {
25
27
  return typeof value === "number" && Number.isFinite(value) && value >= 0;
26
28
  }
27
29
 
30
+ function safeNonNegativeInteger(value: unknown): value is number {
31
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
32
+ }
33
+
34
+ function incrementableNonNegativeInteger(value: unknown): value is number {
35
+ return safeNonNegativeInteger(value) && value < Number.MAX_SAFE_INTEGER;
36
+ }
37
+
38
+ function addGoalMilliseconds(accumulated: number, interval: number): number {
39
+ const total = accumulated + interval;
40
+ if (!safeNonNegativeInteger(total)) throw new Error("Goal active duration exceeds the safe integer range");
41
+ return total;
42
+ }
43
+
28
44
  function isUnknownRecord(value: unknown): value is Record<string, unknown> {
29
45
  return typeof value === "object" && value !== null && !Array.isArray(value);
30
46
  }
@@ -47,6 +63,14 @@ function isAbsoluteFilePath(value: string): boolean {
47
63
  return path.isAbsolute(value) || path.win32.isAbsolute(value);
48
64
  }
49
65
 
66
+ function stripUnquotedPathPunctuation(value: string): string {
67
+ const pathWithoutMarks = value.replace(/[.!?]+$/u, "");
68
+ const trailingClosers = pathWithoutMarks.match(/\)+$/u)?.[0].length ?? 0;
69
+ const unmatchedClosers = Math.max(0, pathWithoutMarks.split(")").length - pathWithoutMarks.split("(").length);
70
+ const punctuationLength = Math.min(trailingClosers, unmatchedClosers);
71
+ return pathWithoutMarks.slice(0, punctuationLength ? -punctuationLength : undefined);
72
+ }
73
+
50
74
  function isGoalFileVerification(value: unknown): value is GoalFileVerification {
51
75
  return isUnknownRecord(value)
52
76
  && value.kind === "file"
@@ -56,12 +80,18 @@ function isGoalFileVerification(value: unknown): value is GoalFileVerification {
56
80
  && isGoalFileBaseline(value.baseline);
57
81
  }
58
82
 
83
+ function isMaxTurns(value: unknown): value is number {
84
+ return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= GOAL_MAX_TURNS;
85
+ }
86
+
59
87
  function isGoalBlockerAudit(value: unknown, turns: number, status: GoalStatus): value is GoalBlockerAudit {
60
88
  if (!isUnknownRecord(value)
61
89
  || typeof value.key !== "string"
62
90
  || !/^[a-z0-9][a-z0-9._-]{0,119}$/u.test(value.key)
63
91
  || 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) {
92
+ || typeof value.lastTurn !== "number" || !Number.isInteger(value.lastTurn) || value.lastTurn < 1 || value.lastTurn > turns
93
+ || value.evidence !== undefined && (typeof value.evidence !== "string"
94
+ || value.evidence !== value.evidence.trim() || !value.evidence || value.evidence.length > 2_000)) {
65
95
  return false;
66
96
  }
67
97
  if (status === "complete") return false;
@@ -86,21 +116,22 @@ export function parseGoalState(value: unknown): GoalState | undefined {
86
116
  resumeAfterManualCompaction,
87
117
  blockerAudit,
88
118
  verification,
119
+ maxTurns,
89
120
  } = value;
90
121
  if (version !== GOAL_VERSION
91
- || typeof revision !== "number" || !Number.isInteger(revision) || revision < 1
122
+ || !incrementableNonNegativeInteger(revision) || revision < 1
92
123
  || typeof objective !== "string" || !objective.trim() || [...objective].length > GOAL_OBJECTIVE_LIMIT
93
124
  || !isGoalStatus(status)
94
- || !finiteNonNegative(createdAt)
95
- || !finiteNonNegative(updatedAt)
96
- || !finiteNonNegative(activeMilliseconds)
97
- || typeof turns !== "number" || !Number.isInteger(turns) || turns < 0
125
+ || !safeNonNegativeInteger(createdAt)
126
+ || !safeNonNegativeInteger(updatedAt)
127
+ || !incrementableNonNegativeInteger(activeMilliseconds)
128
+ || !incrementableNonNegativeInteger(turns)
98
129
  || blockedAuditStartTurn !== undefined
99
- && (typeof blockedAuditStartTurn !== "number" || !Number.isInteger(blockedAuditStartTurn)
100
- || blockedAuditStartTurn < 0 || blockedAuditStartTurn > turns)
101
- || !finiteNonNegative(baselineTokens)
130
+ && (!safeNonNegativeInteger(blockedAuditStartTurn) || blockedAuditStartTurn > turns)
131
+ || !safeNonNegativeInteger(baselineTokens)
102
132
  || result !== undefined && typeof result !== "string"
103
133
  || verification !== undefined && !isGoalFileVerification(verification)
134
+ || maxTurns !== undefined && !isMaxTurns(maxTurns)
104
135
  || resumeAfterManualCompaction !== undefined && resumeAfterManualCompaction !== true
105
136
  || blockerAudit !== undefined && !isGoalBlockerAudit(blockerAudit, turns, status)) {
106
137
  return undefined;
@@ -117,10 +148,11 @@ export function parseGoalState(value: unknown): GoalState | undefined {
117
148
  blockedAuditStartTurn: blockedAuditStartTurn ?? 0,
118
149
  baselineTokens,
119
150
  ...(verification === undefined ? {} : { verification }),
151
+ ...(maxTurns === undefined ? {} : { maxTurns }),
120
152
  };
121
153
  switch (status) {
122
154
  case "active":
123
- if (!finiteNonNegative(activeStartedAt) || resumeAfterManualCompaction !== undefined) return undefined;
155
+ if (!safeNonNegativeInteger(activeStartedAt) || resumeAfterManualCompaction !== undefined) return undefined;
124
156
  return {
125
157
  ...common,
126
158
  status,
@@ -201,13 +233,26 @@ export async function captureGoalFileBaseline(
201
233
  }
202
234
  }
203
235
 
204
- /** Captures one explicit absolute output path so goal completion can verify its creation or modification. */
205
- export async function inferGoalVerification(objective: string): Promise<GoalFileVerification | undefined> {
236
+ /** Captures one explicit output path so goal completion can verify its creation or modification. */
237
+ export async function inferGoalVerification(objective: string, cwd: string): Promise<GoalFileVerification | undefined> {
238
+ const candidates: string[] = [];
206
239
  const destination = /\b(?:create|write|save|generate)\b[^\r\n]{0,160}?\b(?:file|document|markdown|report|spreadsheet|presentation|image)\b\s+(?:to|at|as|destination(?:\s+is)?|output(?:\s+(?:to|at))?)\b\s*(?:`([^`\r\n]+)`|"([^"\r\n]+)"|'([^'\r\n]+)'|([A-Za-z]:\\[^\s,;]+|\/[^\s,;]+))/giu;
207
- const paths = [...objective.matchAll(destination)]
208
- .map((match) => (match[1] ?? match[2] ?? match[3] ?? match[4] ?? "").trim())
209
- .filter(isAbsoluteFilePath);
210
- const unique = [...new Set(paths)];
240
+ for (const match of objective.matchAll(destination)) {
241
+ const quoted = match[1] ?? match[2] ?? match[3];
242
+ candidates.push(quoted !== undefined ? quoted.trim() : stripUnquotedPathPunctuation((match[4] ?? "").trim()));
243
+ }
244
+ const direct = /\b(?:update|edit|fix|refactor|migrate)\s+(`([^`\r\n]+)`|"([^"\r\n]+)"|'([^'\r\n]+)')/giu;
245
+ for (const match of objective.matchAll(direct)) {
246
+ const quoted = match[2] ?? match[3] ?? match[4];
247
+ if (quoted !== undefined) candidates.push(quoted.trim());
248
+ }
249
+ const resolved: string[] = [];
250
+ for (const raw of candidates) {
251
+ if (!raw || /^(?:https?|file):\/\//iu.test(raw) || /[\\\/]$/u.test(raw)) continue;
252
+ const absolute = path.isAbsolute(raw) || path.win32.isAbsolute(raw) ? raw : path.resolve(cwd, raw);
253
+ if (isAbsoluteFilePath(absolute)) resolved.push(absolute);
254
+ }
255
+ const unique = [...new Set(resolved)];
211
256
  const filePath = unique.length === 1 ? unique[0] : undefined;
212
257
  return filePath ? { kind: "file", path: filePath, baseline: await captureGoalFileBaseline(filePath) } : undefined;
213
258
  }
@@ -250,7 +295,7 @@ export function validateGoalObjective(input: string): string | undefined {
250
295
 
251
296
  export function goalElapsedMilliseconds(state: GoalState, now: number): number {
252
297
  const activeInterval = state.status === "active" ? Math.max(0, now - state.activeStartedAt) : 0;
253
- return state.activeMilliseconds + activeInterval;
298
+ return addGoalMilliseconds(state.activeMilliseconds, activeInterval);
254
299
  }
255
300
 
256
301
  export function commonGoalState(state: GoalState): GoalStateCommon {
@@ -265,13 +310,14 @@ export function commonGoalState(state: GoalState): GoalStateCommon {
265
310
  blockedAuditStartTurn: state.blockedAuditStartTurn,
266
311
  baselineTokens: state.baselineTokens,
267
312
  ...(state.verification === undefined ? {} : { verification: state.verification }),
313
+ ...(state.maxTurns === undefined ? {} : { maxTurns: state.maxTurns }),
268
314
  };
269
315
  }
270
316
 
271
317
  export function stopGoalClock(state: GoalState, now: number): GoalStateCommon {
272
318
  const common = commonGoalState(state);
273
319
  return state.status === "active"
274
- ? { ...common, activeMilliseconds: common.activeMilliseconds + Math.max(0, now - state.activeStartedAt) }
320
+ ? { ...common, activeMilliseconds: addGoalMilliseconds(common.activeMilliseconds, Math.max(0, now - state.activeStartedAt)) }
275
321
  : common;
276
322
  }
277
323
 
@@ -280,6 +326,7 @@ export function createNewGoalState(
280
326
  baselineTokens: number,
281
327
  verification: GoalFileVerification | undefined,
282
328
  now: number,
329
+ controls: { maxTurns?: number } = {},
283
330
  ): GoalState {
284
331
  return {
285
332
  version: GOAL_VERSION,
@@ -294,26 +341,7 @@ export function createNewGoalState(
294
341
  blockedAuditStartTurn: 0,
295
342
  baselineTokens,
296
343
  ...(verification === undefined ? {} : { verification }),
297
- };
298
- }
299
-
300
- export function editGoalState(
301
- state: GoalState,
302
- objective: string,
303
- verification: GoalFileVerification | undefined,
304
- now: number,
305
- ): GoalState {
306
- const current = stopGoalClock(state, now);
307
- const { verification: _previousVerification, ...common } = current;
308
- return {
309
- ...common,
310
- revision: current.revision + 1,
311
- objective,
312
- status: "active",
313
- updatedAt: now,
314
- activeStartedAt: now,
315
- blockedAuditStartTurn: current.turns,
316
- ...(verification === undefined ? {} : { verification }),
344
+ ...(controls.maxTurns === undefined ? {} : { maxTurns: controls.maxTurns }),
317
345
  };
318
346
  }
319
347
 
package/killeros/hooks.ts CHANGED
@@ -2,6 +2,7 @@ import {
2
2
  spawn,
3
3
  type SpawnOptionsWithStdioTuple,
4
4
  } from "node:child_process";
5
+ import { EventEmitter } from "node:events";
5
6
  import { closeSync, existsSync, fstatSync, lstatSync, openSync, readSync, realpathSync } from "node:fs";
6
7
  import path from "node:path";
7
8
  import { StringDecoder } from "node:string_decoder";
@@ -58,6 +59,7 @@ const HOOK_EVENTS: readonly KillerosHookEvent[] = ["tool_call", "tool_result", "
58
59
  const HOOK_CONFIG_LIMIT = 64 * 1024;
59
60
  const HOOK_OUTPUT_LIMIT = 16 * 1024;
60
61
  const HOOK_PAYLOAD_LIMIT = 8_000;
62
+ const HOOK_TIMEOUT_DEFAULT_MS = 30_000;
61
63
  const HOOK_TIMEOUT_MAX_MS = 300_000;
62
64
 
63
65
  // Reads executable project configuration through a bounded, project-local file descriptor.
@@ -109,7 +111,7 @@ function isUnknownRecord(value: unknown): value is Record<string, unknown> {
109
111
  return typeof value === "object" && value !== null && !Array.isArray(value);
110
112
  }
111
113
 
112
- function loadKillerosHooks(ctx: ExtensionContext): KillerosHookConfig {
114
+ function loadKillerosConfig(ctx: ExtensionContext): KillerosHookConfig {
113
115
  const configPath = path.join(ctx.cwd, CONFIG_DIR_NAME, "killeros-hooks.json");
114
116
  if (!existsSync(configPath)) return {};
115
117
  const displayPath = safeTerminalText(configPath).replaceAll("\n", "");
@@ -166,6 +168,7 @@ function loadKillerosHooks(ctx: ExtensionContext): KillerosHookConfig {
166
168
  }
167
169
  hooks[event] = accepted;
168
170
  }
171
+
169
172
  return { hooks };
170
173
  } catch (error) {
171
174
  reportError(ctx, `Invalid ${CONFIG_DIR_NAME}/killeros-hooks.json`, error);
@@ -265,7 +268,7 @@ export function executeHook(
265
268
  command,
266
269
  cwd,
267
270
  environment,
268
- timeoutMs = 30_000,
271
+ timeoutMs = HOOK_TIMEOUT_DEFAULT_MS,
269
272
  spawnProcess = spawn,
270
273
  signal,
271
274
  } = options;
@@ -331,18 +334,19 @@ export function executeHook(
331
334
  }, 2_000);
332
335
  return;
333
336
  }
334
- terminateHookProcess(child, false);
335
337
  forceTimer = setTimeout(() => {
336
338
  if (completed) return;
337
339
  terminateHookProcess(child, true);
338
340
  settleTimer = setTimeout(() => finish(terminationCode(), true), 1_000);
339
341
  }, 1_000);
342
+ terminateHookProcess(child, false);
340
343
  };
341
344
  const abort = (): void => beginTermination("cancelled");
342
- signal?.addEventListener("abort", abort, { once: true });
343
- if (signal?.aborted) beginTermination("cancelled");
344
345
  child.stdout.on("data", (chunk) => appendBounded(stdout, chunk));
345
346
  child.stderr.on("data", (chunk) => appendBounded(stderr, chunk));
347
+ const captureStreamError = (error: Error): void => appendBounded(stderr, error.message);
348
+ if (child.stdout instanceof EventEmitter) child.stdout.on("error", captureStreamError);
349
+ if (child.stderr instanceof EventEmitter) child.stderr.on("error", captureStreamError);
346
350
  child.on("error", (error) => {
347
351
  appendBounded(stderr, error.message);
348
352
  if (!windowsCleanupPending) finish(termination ? terminationCode() : 1);
@@ -350,12 +354,23 @@ export function executeHook(
350
354
  child.once("close", (code) => {
351
355
  if (!windowsCleanupPending) finish(termination ? terminationCode() : code ?? 1);
352
356
  });
353
- timer = setTimeout(() => beginTermination("timeout"), Math.max(1, Math.min(timeoutMs, HOOK_TIMEOUT_MAX_MS)));
357
+ if (completed) return;
358
+ const boundedTimeoutMs = Number.isNaN(timeoutMs)
359
+ ? HOOK_TIMEOUT_DEFAULT_MS
360
+ : Math.max(1, Math.min(timeoutMs, HOOK_TIMEOUT_MAX_MS));
361
+ timer = setTimeout(() => beginTermination("timeout"), boundedTimeoutMs);
362
+ signal?.addEventListener("abort", abort, { once: true });
363
+ if (signal?.aborted) beginTermination("cancelled");
354
364
  });
355
365
  }
356
366
 
357
367
  function serializeHookPayload(payload: unknown): string {
358
- const serialized = JSON.stringify(payload) ?? "null";
368
+ let serialized: string;
369
+ try {
370
+ serialized = JSON.stringify(payload) ?? "null";
371
+ } catch {
372
+ return JSON.stringify({ serializationError: true });
373
+ }
359
374
  if (serialized.length <= HOOK_PAYLOAD_LIMIT) return serialized;
360
375
  const previewLength = Math.floor((HOOK_PAYLOAD_LIMIT - 64) / 2);
361
376
  return JSON.stringify({ truncated: true, preview: serialized.slice(0, previewLength) });
@@ -376,7 +391,7 @@ function hookFailureMessage(result: HookExecutionResult): string {
376
391
 
377
392
  export function registerLifecycleHooks(pi: ExtensionAPI): void {
378
393
  let config: KillerosHookConfig = {};
379
- pi.on("session_start", (_event, ctx) => { config = loadKillerosHooks(ctx); });
394
+ pi.on("session_start", (_event, ctx) => { config = loadKillerosConfig(ctx); });
380
395
 
381
396
  pi.on("tool_call", async (event, ctx) => {
382
397
  for (const hook of config.hooks?.tool_call ?? []) {
@@ -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 =
@@ -49,6 +50,7 @@ export interface GoalStateCommon {
49
50
  blockedAuditStartTurn: number;
50
51
  baselineTokens: number;
51
52
  verification?: GoalFileVerification;
53
+ maxTurns?: number;
52
54
  }
53
55
 
54
56
  export type GoalState = GoalStateCommon & (