killeros 2.0.2 → 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/killeros/goals.ts CHANGED
@@ -3,11 +3,10 @@ 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";
10
- import type { CompactionRuntime, GoalRuntime, GoalState, GoalStatus, InitRuntime } from "./runtime.ts";
9
+ import type { GoalRuntime, GoalState, GoalStatus, InitRuntime } from "./runtime.ts";
11
10
 
12
11
  const GOAL_ENTRY_TYPE = "killeros-goal";
13
12
  const GOAL_CONTINUATION_TYPE = "killeros-goal-continuation";
@@ -21,6 +20,16 @@ interface GoalEntryData {
21
20
  state: GoalState | null;
22
21
  }
23
22
 
23
+ interface GoalTransitionOptions {
24
+ resetBlockedAudit?: boolean;
25
+ resumeAfterManualCompaction?: true;
26
+ }
27
+
28
+ interface RestoredGoalState {
29
+ state?: GoalState;
30
+ recoveryProven: boolean;
31
+ }
32
+
24
33
  const GoalUpdateParams = Type.Object({
25
34
  status: Type.Union([Type.Literal("complete"), Type.Literal("blocked")], {
26
35
  description: "Mark the active goal complete or blocked",
@@ -61,7 +70,9 @@ function parseGoalState(value: unknown): GoalState | undefined {
61
70
  && (!Number.isInteger(candidate.blockedAuditStartTurn) || candidate.blockedAuditStartTurn < 0 || candidate.blockedAuditStartTurn > candidate.turns!)
62
71
  || !finiteNonNegative(candidate.baselineTokens)
63
72
  || candidate.activeStartedAt !== undefined && !finiteNonNegative(candidate.activeStartedAt)
64
- || candidate.result !== undefined && typeof candidate.result !== "string") {
73
+ || candidate.result !== undefined && typeof candidate.result !== "string"
74
+ || candidate.resumeAfterManualCompaction !== undefined && candidate.resumeAfterManualCompaction !== true
75
+ || candidate.resumeAfterManualCompaction === true && candidate.status !== "paused") {
65
76
  return undefined;
66
77
  }
67
78
  return {
@@ -77,6 +88,7 @@ function parseGoalState(value: unknown): GoalState | undefined {
77
88
  blockedAuditStartTurn: candidate.blockedAuditStartTurn ?? 0,
78
89
  baselineTokens: candidate.baselineTokens,
79
90
  result: candidate.result,
91
+ resumeAfterManualCompaction: candidate.resumeAfterManualCompaction,
80
92
  };
81
93
  }
82
94
 
@@ -88,21 +100,26 @@ function goalBranchEntries(ctx: ExtensionContext): ReturnType<ExtensionContext["
88
100
  }
89
101
  }
90
102
 
91
- function restoreGoalState(ctx: ExtensionContext): GoalState | undefined {
103
+ function restoreGoalState(ctx: ExtensionContext): RestoredGoalState {
92
104
  const entries = goalBranchEntries(ctx);
93
105
  for (let index = entries.length - 1; index >= 0; index -= 1) {
94
106
  const entry = entries[index];
95
107
  if (entry?.type !== "custom" || entry.customType !== GOAL_ENTRY_TYPE) continue;
96
108
  const data = entry.data as Partial<GoalEntryData> | undefined;
97
- if (!data || data.version !== GOAL_VERSION) return undefined;
98
- if (data.state === null) return undefined;
109
+ if (!data || data.version !== GOAL_VERSION || data.state === null) {
110
+ return { state: undefined, recoveryProven: false };
111
+ }
99
112
  const restored = parseGoalState(data.state);
100
- if (!restored) return undefined;
101
- return restored.status === "active"
113
+ if (!restored) return { state: undefined, recoveryProven: false };
114
+ const state = restored.status === "active"
102
115
  ? { ...restored, activeStartedAt: Date.now() }
103
116
  : { ...restored, activeStartedAt: undefined };
117
+ const recoveryProven = state.status === "paused"
118
+ && state.resumeAfterManualCompaction === true
119
+ && entries.slice(index + 1).some((candidate) => candidate.type === "compaction");
120
+ return { state, recoveryProven };
104
121
  }
105
- return undefined;
122
+ return { state: undefined, recoveryProven: false };
106
123
  }
107
124
 
108
125
  export function goalElapsedMilliseconds(state: GoalState, now = Date.now()): number {
@@ -152,7 +169,7 @@ function transitionGoal(
152
169
  event: GoalEntryEvent,
153
170
  status: GoalStatus,
154
171
  result?: string,
155
- resetBlockedAudit = false,
172
+ options: GoalTransitionOptions = {},
156
173
  ): GoalState {
157
174
  const current = runtime.state;
158
175
  if (!current) throw new Error("No goal is set");
@@ -164,8 +181,9 @@ function transitionGoal(
164
181
  status,
165
182
  updatedAt: now,
166
183
  activeStartedAt: status === "active" ? now : undefined,
167
- blockedAuditStartTurn: resetBlockedAudit ? stopped.turns : stopped.blockedAuditStartTurn,
184
+ blockedAuditStartTurn: options.resetBlockedAudit ? stopped.turns : stopped.blockedAuditStartTurn,
168
185
  result,
186
+ resumeAfterManualCompaction: options.resumeAfterManualCompaction,
169
187
  };
170
188
  persistGoalState(pi, runtime, event, next);
171
189
  if (status !== "active") runtime.continuationScheduled = false;
@@ -209,7 +227,12 @@ export function pauseGoalAfterFailure(
209
227
  try {
210
228
  transitionGoal(pi, runtime, "error", "paused", reason);
211
229
  } catch {
212
- runtime.state = runtime.state ? { ...stopGoalClock(runtime.state, Date.now()), status: "paused", result: reason } : undefined;
230
+ runtime.state = runtime.state ? {
231
+ ...stopGoalClock(runtime.state, Date.now()),
232
+ status: "paused",
233
+ result: reason,
234
+ resumeAfterManualCompaction: undefined,
235
+ } : undefined;
213
236
  runtime.persistenceRetryNeeded = true;
214
237
  runtime.continuationScheduled = false;
215
238
  runtime.requestRender?.();
@@ -217,6 +240,56 @@ export function pauseGoalAfterFailure(
217
240
  ctx.ui.notify(`Goal paused: ${reason}\n${recoveryInstruction}`, "error");
218
241
  }
219
242
 
243
+ function pauseGoalForPossibleManualCompaction(
244
+ pi: ExtensionAPI,
245
+ runtime: GoalRuntime,
246
+ ctx: ExtensionContext,
247
+ reason: string,
248
+ ): void {
249
+ if (runtime.state?.status !== "active") return;
250
+ try {
251
+ transitionGoal(pi, runtime, "error", "paused", reason, {
252
+ resumeAfterManualCompaction: true,
253
+ });
254
+ } catch {
255
+ runtime.state = runtime.state ? {
256
+ ...stopGoalClock(runtime.state, Date.now()),
257
+ status: "paused",
258
+ result: reason,
259
+ resumeAfterManualCompaction: true,
260
+ } : undefined;
261
+ runtime.persistenceRetryNeeded = true;
262
+ runtime.continuationScheduled = false;
263
+ runtime.requestRender?.();
264
+ }
265
+ ctx.ui.notify(
266
+ "Goal paused because the turn was aborted. If /compact is running, KillerOS will resume after Pi saves the summary. Run /goal pause to keep it paused.",
267
+ "warning",
268
+ );
269
+ }
270
+
271
+ function recoverGoalAfterManualCompaction(
272
+ pi: ExtensionAPI,
273
+ runtime: GoalRuntime,
274
+ initState: InitRuntime,
275
+ ctx: ExtensionContext,
276
+ ): boolean {
277
+ if (runtime.state?.status !== "paused"
278
+ || runtime.state.resumeAfterManualCompaction !== true
279
+ || initState.active) return false;
280
+ try {
281
+ transitionGoal(pi, runtime, "resume", "active", undefined, { resetBlockedAudit: true });
282
+ } catch (error) {
283
+ runtime.persistenceRetryNeeded = true;
284
+ reportError(ctx, "Manual compaction succeeded, but the goal could not be resumed", error);
285
+ return false;
286
+ }
287
+ runtime.continuationScheduled = false;
288
+ ctx.ui.notify("Manual compaction complete. Goal resumed.", "info");
289
+ setImmediate(() => scheduleGoalContinuation(pi, runtime, initState, ctx));
290
+ return true;
291
+ }
292
+
220
293
  function scheduleGoalContinuation(
221
294
  pi: ExtensionAPI,
222
295
  runtime: GoalRuntime,
@@ -259,6 +332,8 @@ function goalInstructions(state: GoalState, heading: string): string {
259
332
  "Objective:",
260
333
  state.objective,
261
334
  "",
335
+ "Treat the exact objective above from /goal as authoritative; a compaction summary may describe it but does not replace it.",
336
+ "If the current context contains a compaction summary, take its first concrete next step after checking the current repository state.",
262
337
  "Continue making concrete progress toward this unchanged objective. Re-check repository state and prior results instead of repeating work.",
263
338
  "Do not stop merely because one response is complete: KillerOS will start another goal turn while the goal remains active.",
264
339
  "Before declaring completion, audit every part of the objective and verify the relevant results. Then call killeros_goal_update with status complete and concise evidence.",
@@ -279,7 +354,6 @@ function goalContinuationMessage(state: GoalState, ctx: ExtensionContext): strin
279
354
  sections.push(`<personal_instructions source=${JSON.stringify(personal.source)}>\n${personal.content}\n</personal_instructions>`);
280
355
  }
281
356
  }
282
- sections.push(CONCISE_SYSTEM_PROMPT);
283
357
  return sections.join("\n\n");
284
358
  }
285
359
 
@@ -354,37 +428,26 @@ export function registerGoal(
354
428
  },
355
429
  });
356
430
 
357
- pi.on("session_start", (_event, ctx) => {
358
- runtime.state = restoreGoalState(ctx);
431
+ const restoreGoal = (ctx: ExtensionContext): void => {
432
+ const restored = restoreGoalState(ctx);
433
+ runtime.state = restored.state;
359
434
  runtime.continuationScheduled = false;
360
435
  runtime.continuationHeld = false;
361
- runtime.continuationHeldForCompaction = false;
362
436
  runtime.goalTurnInFlight = false;
363
437
  runtime.agentEndObserved = false;
364
438
  runtime.persistenceRetryNeeded = false;
365
439
  runtime.lastStopReason = undefined;
366
440
  runtime.lastError = undefined;
367
441
  runtime.requestRender?.();
368
- if (runtime.state?.status === "active") {
442
+ if (restored.recoveryProven) {
443
+ recoverGoalAfterManualCompaction(pi, runtime, initState, ctx);
444
+ } else if (runtime.state?.status === "active") {
369
445
  setImmediate(() => scheduleGoalContinuation(pi, runtime, initState, ctx));
370
446
  }
371
- });
447
+ };
372
448
 
373
- pi.on("session_tree", (_event, ctx) => {
374
- runtime.state = restoreGoalState(ctx);
375
- runtime.continuationScheduled = false;
376
- runtime.continuationHeld = false;
377
- runtime.continuationHeldForCompaction = false;
378
- runtime.goalTurnInFlight = false;
379
- runtime.agentEndObserved = false;
380
- runtime.persistenceRetryNeeded = false;
381
- runtime.lastStopReason = undefined;
382
- runtime.lastError = undefined;
383
- runtime.requestRender?.();
384
- if (runtime.state?.status === "active") {
385
- setImmediate(() => scheduleGoalContinuation(pi, runtime, initState, ctx));
386
- }
387
- });
449
+ pi.on("session_start", (_event, ctx) => restoreGoal(ctx));
450
+ pi.on("session_tree", (_event, ctx) => restoreGoal(ctx));
388
451
 
389
452
  pi.on("session_shutdown", (_event, ctx) => {
390
453
  if (runtime.state?.status === "active") {
@@ -403,7 +466,6 @@ export function registerGoal(
403
466
  runtime.state = undefined;
404
467
  runtime.continuationScheduled = false;
405
468
  runtime.continuationHeld = false;
406
- runtime.continuationHeldForCompaction = false;
407
469
  runtime.goalTurnInFlight = false;
408
470
  runtime.agentEndObserved = false;
409
471
  runtime.persistenceRetryNeeded = false;
@@ -423,6 +485,7 @@ export function registerGoal(
423
485
  turns: current.turns + 1,
424
486
  updatedAt: now,
425
487
  activeStartedAt: current.activeStartedAt ?? now,
488
+ resumeAfterManualCompaction: undefined,
426
489
  };
427
490
  try {
428
491
  persistGoalState(pi, runtime, "turn", next);
@@ -507,7 +570,7 @@ export function registerGoal(
507
570
  return;
508
571
  }
509
572
  if (runtime.state.status === "paused") {
510
- if (!runtime.persistenceRetryNeeded) {
573
+ if (!runtime.persistenceRetryNeeded && runtime.state.resumeAfterManualCompaction !== true) {
511
574
  ctx.ui.notify("Goal is already paused", "info");
512
575
  return;
513
576
  }
@@ -516,11 +579,16 @@ export function registerGoal(
516
579
  ...runtime.state,
517
580
  revision: runtime.state.revision + 1,
518
581
  updatedAt: now,
582
+ resumeAfterManualCompaction: undefined,
519
583
  };
520
584
  try {
521
585
  persistGoalState(pi, runtime, "pause", checkpoint);
522
- ctx.ui.notify("Goal pause saved", "info");
586
+ ctx.ui.notify("Goal pause saved. Goal remains paused. Automatic compaction recovery is off.", "info");
523
587
  } catch (error) {
588
+ runtime.state = checkpoint;
589
+ runtime.persistenceRetryNeeded = true;
590
+ runtime.continuationScheduled = false;
591
+ runtime.requestRender?.();
524
592
  reportError(ctx, "Goal pause still could not be saved", error);
525
593
  }
526
594
  return;
@@ -562,7 +630,7 @@ export function registerGoal(
562
630
  return;
563
631
  }
564
632
  try {
565
- transitionGoal(pi, runtime, "resume", "active", undefined, true);
633
+ transitionGoal(pi, runtime, "resume", "active", undefined, { resetBlockedAudit: true });
566
634
  runtime.continuationScheduled = false;
567
635
  if (scheduleGoalContinuation(pi, runtime, initState, ctx)) ctx.ui.notify("Goal resumed", "info");
568
636
  } catch (error) {
@@ -620,6 +688,7 @@ export function registerGoal(
620
688
  activeStartedAt: now,
621
689
  blockedAuditStartTurn: current.turns,
622
690
  result: undefined,
691
+ resumeAfterManualCompaction: undefined,
623
692
  };
624
693
  try {
625
694
  persistGoalState(pi, runtime, "edit", next);
@@ -720,7 +789,6 @@ export function registerGoalSettlement(
720
789
  pi: ExtensionAPI,
721
790
  runtime: GoalRuntime,
722
791
  initState: InitRuntime,
723
- compactionRuntime?: CompactionRuntime,
724
792
  ): void {
725
793
  pi.on("agent_settled", (_event, ctx) => {
726
794
  const wasGoalTurn = runtime.goalTurnInFlight;
@@ -739,17 +807,18 @@ export function registerGoalSettlement(
739
807
  pauseGoalAfterFailure(pi, runtime, ctx, "the goal turn ended without an agent result");
740
808
  return;
741
809
  }
742
- if (runtime.lastStopReason === "error" || runtime.lastStopReason === "aborted") {
743
- const reason = runtime.lastError || (runtime.lastStopReason === "aborted" ? "the agent turn was aborted" : "the agent turn failed");
810
+ if (runtime.lastStopReason === "aborted") {
811
+ const reason = runtime.lastError || "the agent turn was aborted";
744
812
  runtime.lastStopReason = undefined;
745
813
  runtime.lastError = undefined;
746
- pauseGoalAfterFailure(pi, runtime, ctx, reason);
814
+ pauseGoalForPossibleManualCompaction(pi, runtime, ctx, reason);
747
815
  return;
748
816
  }
749
- if (compactionRuntime?.compactionInFlight) {
750
- runtime.continuationHeld = true;
751
- runtime.continuationHeldForCompaction = true;
752
- runtime.requestRender?.();
817
+ if (runtime.lastStopReason === "error") {
818
+ const reason = runtime.lastError || "the agent turn failed";
819
+ runtime.lastStopReason = undefined;
820
+ runtime.lastError = undefined;
821
+ pauseGoalAfterFailure(pi, runtime, ctx, reason);
753
822
  return;
754
823
  }
755
824
  runtime.lastStopReason = undefined;
@@ -757,16 +826,8 @@ export function registerGoalSettlement(
757
826
  scheduleGoalContinuation(pi, runtime, initState, ctx);
758
827
  });
759
828
 
760
- if (compactionRuntime) {
761
- pi.on("session_compact", (_event, ctx) => {
762
- if (!runtime.continuationHeldForCompaction) return;
763
- runtime.continuationHeldForCompaction = false;
764
- if (!runtime.continuationHeld || runtime.state?.status !== "active" || initState.active) {
765
- runtime.continuationHeld = false;
766
- return;
767
- }
768
- runtime.continuationHeld = false;
769
- setImmediate(() => scheduleGoalContinuation(pi, runtime, initState, ctx));
770
- });
771
- }
829
+ pi.on("session_compact", (event, ctx) => {
830
+ if (event.reason !== "manual") return;
831
+ recoverGoalAfterManualCompaction(pi, runtime, initState, ctx);
832
+ });
772
833
  }
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(command: string, cwd: string, environment: Record<string, string>, timeoutMs = 30_000, spawnProcess: typeof spawn = spawn): Promise<HookExecutionResult> {
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 timedOut = false;
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, unconfirmed = false): void => {
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
- resolve({ code, stdout, stderr, timedOut, exitUnconfirmed });
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
- child.stdout.on("data", (chunk) => { stdout = appendBounded(stdout, chunk); });
140
- child.stderr.on("data", (chunk) => { stderr = appendBounded(stderr, chunk); });
141
- child.on("error", (error) => {
142
- stderr = appendBounded(stderr, error.message);
143
- finish(timedOut ? 124 : 1);
144
- });
145
- child.once("close", (code) => finish(timedOut ? 124 : code ?? 1));
146
- timer = setTimeout(() => {
147
- timedOut = true;
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(124, true), 1_000);
164
+ settleTimer = setTimeout(() => finish(terminationCode(), true), 1_000);
153
165
  }, 1_000);
154
- }, Math.max(1_000, Math.min(timeoutMs, 300_000)));
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
  });