killeros 2.1.27 → 2.1.28

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.
@@ -1,6 +1,15 @@
1
- import { type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext } from "@earendil-works/pi-coding-agent";
1
+ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { reportError } from "./errors.ts";
3
- import { beginGoalTurnState, checkpointActiveGoalState, GOAL_VERSION, parseGoalState, pauseGoalState, transitionGoalState, type GoalTransitionOptions } from "./goal-state.ts";
3
+ import {
4
+ beginGoalTurnState,
5
+ boundGoalText,
6
+ checkpointActiveGoalState,
7
+ GOAL_VERSION,
8
+ parseGoalState,
9
+ pauseGoalState,
10
+ transitionGoalState,
11
+ type GoalTransitionOptions,
12
+ } from "./goal-state.ts";
4
13
  import { resolvePersonalInstructions } from "./personal-instructions.ts";
5
14
  import type { GoalRuntime, GoalState, GoalStatus } from "./runtime.ts";
6
15
  import { safeTerminalText } from "./safe-terminal-text.ts";
@@ -9,7 +18,7 @@ export const GOAL_ENTRY_TYPE = "killeros-goal";
9
18
  const GOAL_CONTINUATION_TYPE = "killeros-goal-continuation";
10
19
  export const GOAL_UPDATE_TOOL = "killeros_goal_update";
11
20
 
12
- export type GoalEntryEvent = "set" | "replace" | "limit" | "turn" | "pause" | "resume" | "blocked" | "complete" | "error" | "clear" | "checkpoint" | "blocker-audit";
21
+ export type GoalEntryEvent = "set" | "replace" | "limit" | "turn" | "continue" | "pause" | "resume" | "blocked" | "complete" | "error" | "clear" | "checkpoint" | "blocker-audit";
13
22
  export interface GoalEntryData {
14
23
  version: 1;
15
24
  event: GoalEntryEvent;
@@ -75,18 +84,32 @@ export function sumGoalTokens(ctx: ExtensionContext): number {
75
84
  }
76
85
 
77
86
  function setGoalUpdateToolActive(pi: ExtensionAPI, active: boolean): void {
78
- const activeTools = pi.getActiveTools();
79
- const isActive = activeTools.includes(GOAL_UPDATE_TOOL);
80
- if (active === isActive) return;
81
- pi.setActiveTools(active
82
- ? [...activeTools, GOAL_UPDATE_TOOL]
83
- : activeTools.filter((name) => name !== GOAL_UPDATE_TOOL));
87
+ try {
88
+ const activeTools = pi.getActiveTools();
89
+ const isActive = activeTools.includes(GOAL_UPDATE_TOOL);
90
+ if (active === isActive) return;
91
+ pi.setActiveTools(active
92
+ ? [...activeTools, GOAL_UPDATE_TOOL]
93
+ : activeTools.filter((name) => name !== GOAL_UPDATE_TOOL));
94
+ } catch {
95
+ // Availability is checked again immediately before a goal request.
96
+ }
84
97
  }
85
98
 
86
99
  export function syncGoalUpdateTool(pi: ExtensionAPI, runtime: GoalRuntime): void {
87
100
  setGoalUpdateToolActive(pi, runtime.state?.status === "active");
88
101
  }
89
102
 
103
+ function goalUpdateToolIsAvailable(pi: ExtensionAPI): boolean {
104
+ try {
105
+ const active = pi.getActiveTools();
106
+ if (!active.includes(GOAL_UPDATE_TOOL)) pi.setActiveTools([...active, GOAL_UPDATE_TOOL]);
107
+ return pi.getActiveTools().includes(GOAL_UPDATE_TOOL);
108
+ } catch {
109
+ return false;
110
+ }
111
+ }
112
+
90
113
  export function persistGoalState(
91
114
  pi: ExtensionAPI,
92
115
  runtime: GoalRuntime,
@@ -94,7 +117,6 @@ export function persistGoalState(
94
117
  state: GoalState | undefined,
95
118
  ): void {
96
119
  const data: GoalEntryData = { version: GOAL_VERSION, event, state: state ?? null };
97
- runtime.automaticCompaction = undefined;
98
120
  pi.appendEntry(GOAL_ENTRY_TYPE, data);
99
121
  runtime.state = state;
100
122
  syncGoalUpdateTool(pi, runtime);
@@ -117,6 +139,15 @@ export function transitionGoal(
117
139
  if (status !== "active") {
118
140
  runtime.continuationScheduled = false;
119
141
  runtime.automaticCompaction = undefined;
142
+ if (!options.keepTurnForRecovery) {
143
+ runtime.goalTurn = undefined;
144
+ runtime.goalTurnInFlight = false;
145
+ runtime.agentEndObserved = false;
146
+ }
147
+ } else if (!options.resumeInterruptedTurn) {
148
+ runtime.goalTurn = undefined;
149
+ runtime.goalTurnInFlight = false;
150
+ runtime.agentEndObserved = false;
120
151
  }
121
152
  return next;
122
153
  }
@@ -141,6 +172,7 @@ function clearGoalExecutionFlags(runtime: GoalRuntime): void {
141
172
  runtime.continuationScheduled = false;
142
173
  runtime.goalTurnInFlight = false;
143
174
  runtime.agentEndObserved = false;
175
+ runtime.goalTurn = undefined;
144
176
  runtime.automaticCompaction = undefined;
145
177
  runtime.lastStopReason = undefined;
146
178
  runtime.lastError = undefined;
@@ -156,6 +188,10 @@ export async function stopGoalRun(runtime: GoalRuntime, ctx: ExtensionCommandCon
156
188
  }
157
189
  }
158
190
 
191
+ function safeFailureReason(reason: string): string {
192
+ return boundGoalText(reason) || "an unspecified goal failure";
193
+ }
194
+
159
195
  export function pauseGoalAfterFailure(
160
196
  pi: ExtensionAPI,
161
197
  runtime: GoalRuntime,
@@ -165,7 +201,11 @@ export function pauseGoalAfterFailure(
165
201
  notify = true,
166
202
  ): void {
167
203
  if (runtime.state?.status !== "active") return;
168
- const safeReason = safeTerminalText(reason);
204
+ const safeReason = safeFailureReason(reason);
205
+ const safeRecoveryInstruction = safeReason === "no turn decision"
206
+ && recoveryInstruction === "Run /goal resume after resolving the problem."
207
+ ? "The agent ended without choosing continue, complete, or blocked. Run /goal resume only after choosing to continue."
208
+ : safeTerminalText(recoveryInstruction);
169
209
  try {
170
210
  transitionGoal(pi, runtime, "error", "paused", safeReason);
171
211
  } catch {
@@ -173,11 +213,34 @@ export function pauseGoalAfterFailure(
173
213
  runtime.state = current ? pauseGoalState(current, safeReason, Date.now()) : undefined;
174
214
  syncGoalUpdateTool(pi, runtime);
175
215
  runtime.persistenceRetryNeeded = true;
176
- runtime.continuationScheduled = false;
177
- runtime.automaticCompaction = undefined;
216
+ clearGoalExecutionFlags(runtime);
217
+ runtime.requestRender?.();
218
+ }
219
+ if (notify) ctx.ui.notify(`Goal paused: ${safeReason}\n${safeRecoveryInstruction}`, "error");
220
+ }
221
+
222
+ function pauseGoalBeforeTurn(
223
+ pi: ExtensionAPI,
224
+ runtime: GoalRuntime,
225
+ ctx: ExtensionContext,
226
+ ): void {
227
+ const state = runtime.state;
228
+ if (state?.status !== "active") return;
229
+ const nextTurn = state.turns + 1;
230
+ const reason = "killeros_goal_update is unavailable";
231
+ try {
232
+ transitionGoal(pi, runtime, "error", "paused", reason);
233
+ } catch {
234
+ runtime.state = pauseGoalState(state, reason, Date.now());
235
+ syncGoalUpdateTool(pi, runtime);
236
+ runtime.persistenceRetryNeeded = true;
237
+ clearGoalExecutionFlags(runtime);
178
238
  runtime.requestRender?.();
179
239
  }
180
- if (notify) ctx.ui.notify(`Goal paused: ${safeReason}\n${recoveryInstruction}`, "error");
240
+ ctx.ui.notify(
241
+ `Goal paused before turn ${nextTurn}: ${reason}\nEnable the goal tool, then run /goal resume.`,
242
+ "error",
243
+ );
181
244
  }
182
245
 
183
246
  function beginGoalTurn(
@@ -186,13 +249,15 @@ function beginGoalTurn(
186
249
  ctx: ExtensionContext,
187
250
  current: Extract<GoalState, { status: "active" }>,
188
251
  ): GoalState | undefined {
189
- const next = beginGoalTurnState(current, Date.now());
252
+ let next: GoalState;
190
253
  try {
254
+ next = beginGoalTurnState(current, Date.now());
191
255
  persistGoalState(pi, runtime, "turn", next);
192
256
  } catch (error) {
193
257
  pauseGoalAfterFailure(pi, runtime, ctx, `turn state could not be saved: ${error instanceof Error ? error.message : String(error)}`);
194
258
  return undefined;
195
259
  }
260
+ runtime.goalTurn = { revision: next.revision, turn: next.turns };
196
261
  runtime.goalTurnInFlight = true;
197
262
  runtime.agentEndObserved = false;
198
263
  runtime.lastStopReason = undefined;
@@ -200,40 +265,85 @@ function beginGoalTurn(
200
265
  return next;
201
266
  }
202
267
 
203
- /** Starts one goal turn only after Pi is idle and all competing workflow gates are clear. */
204
- export function scheduleGoalContinuation(
268
+ function sendGoalTurn(
205
269
  pi: ExtensionAPI,
206
270
  runtime: GoalRuntime,
207
271
  ctx: ExtensionContext,
272
+ state: Extract<GoalState, { status: "active" }>,
208
273
  ): boolean {
209
- if (isGoalModeSupported(ctx) && isSavedSession(ctx) && pauseGoalAtTurnLimit(pi, runtime, ctx)) return false;
210
- if (!isGoalModeSupported(ctx)
211
- || !isSavedSession(ctx)
212
- || runtime.state?.status !== "active"
213
- || runtime.continuationScheduled
214
- || runtime.continuationHeld
215
- || runtime.goalTurnInFlight
216
- || !ctx.isIdle()
217
- || ctx.hasPendingMessages()) return false;
218
274
  runtime.continuationScheduled = true;
219
- const next = beginGoalTurn(pi, runtime, ctx, runtime.state);
220
- if (!next) return false;
221
275
  try {
222
276
  pi.sendMessage({
223
277
  customType: GOAL_CONTINUATION_TYPE,
224
- content: goalContinuationMessage(next, ctx),
278
+ content: goalContinuationMessage(state, ctx),
225
279
  display: false,
226
280
  }, { triggerTurn: true, deliverAs: "followUp" });
227
281
  return true;
228
282
  } catch (error) {
229
283
  runtime.continuationScheduled = false;
230
284
  runtime.goalTurnInFlight = false;
285
+ runtime.goalTurn = undefined;
231
286
  pauseGoalAfterFailure(pi, runtime, ctx, `continuation could not start: ${error instanceof Error ? error.message : String(error)}`);
232
287
  return false;
233
288
  }
234
289
  }
235
290
 
291
+ export function resumeInterruptedGoalTurn(
292
+ pi: ExtensionAPI,
293
+ runtime: GoalRuntime,
294
+ ctx: ExtensionContext,
295
+ ): boolean {
296
+ const state = runtime.state;
297
+ if (state?.status !== "active"
298
+ || (state.turnPhase !== "in-flight" && state.turnPhase !== "authorized")
299
+ || !runtime.goalTurnInFlight) return false;
300
+ if (!goalUpdateToolIsAvailable(pi)) {
301
+ pauseGoalBeforeTurn(pi, runtime, ctx);
302
+ return false;
303
+ }
304
+ return sendGoalTurn(pi, runtime, ctx, state);
305
+ }
306
+
307
+ /** Starts one goal turn only after Pi is idle and all competing workflow gates are clear. */
308
+ export function scheduleGoalContinuation(
309
+ pi: ExtensionAPI,
310
+ runtime: GoalRuntime,
311
+ ctx: ExtensionContext,
312
+ guard?: { generation: number; state: GoalState },
313
+ ): boolean {
314
+ if (guard && (guard.generation !== runtime.lifecycleGeneration || runtime.state !== guard.state)) return false;
315
+ if (isGoalModeSupported(ctx) && isSavedSession(ctx) && pauseGoalAtTurnLimit(pi, runtime, ctx)) return false;
316
+ if (!isGoalModeSupported(ctx)
317
+ || !isSavedSession(ctx)
318
+ || runtime.state?.status !== "active"
319
+ || runtime.continuationScheduled
320
+ || runtime.continuationHeld
321
+ || runtime.goalTurnInFlight
322
+ || !ctx.isIdle()
323
+ || ctx.hasPendingMessages()) return false;
324
+
325
+ const current = runtime.state;
326
+ const phase = current.turnPhase;
327
+ const canStart = phase === "ready"
328
+ || phase === "authorized"
329
+ || phase === undefined && current.turns === 0;
330
+ if (!canStart || phase === "authorized" && current.turnDecision === undefined) return false;
331
+ if (!goalUpdateToolIsAvailable(pi)) {
332
+ pauseGoalBeforeTurn(pi, runtime, ctx);
333
+ return false;
334
+ }
335
+ const next = beginGoalTurn(pi, runtime, ctx, current);
336
+ return next !== undefined && sendGoalTurn(pi, runtime, ctx, next as Extract<GoalState, { status: "active" }>);
337
+ }
338
+
236
339
  function goalInstructions(state: GoalState, heading: string): string {
340
+ const previous = state.lastDecision;
341
+ const continuation = previous?.kind === "continue"
342
+ ? [
343
+ "The previous turn's accepted next action was model-reported (not independently verified):",
344
+ previous.nextAction,
345
+ ]
346
+ : [];
237
347
  return [
238
348
  `# ${heading}`,
239
349
  `Status: active · Turn: ${state.turns}`,
@@ -242,10 +352,18 @@ function goalInstructions(state: GoalState, heading: string): string {
242
352
  "",
243
353
  "Treat the exact objective above from /goal as authoritative; a compaction summary may describe it but does not replace it.",
244
354
  "If the current context contains a compaction summary, take its first concrete next step after checking the current repository state.",
355
+ ...continuation,
245
356
  "Continue making concrete progress toward this unchanged objective. Re-check repository state and prior results instead of repeating work.",
246
- "Do not stop merely because one response is complete: KillerOS will start another goal turn while the goal remains active.",
247
- "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.",
248
- "Call killeros_goal_update with status blocked and the same lowercase blockerKey on each turn where one external impasse persists; attempts one and two record the audit, and attempt three marks the goal blocked.",
357
+ ...(state.turnDecision === undefined
358
+ ? [
359
+ "Before ending this turn, choose exactly one accepted goal decision.",
360
+ "After auditing and verifying the whole objective, call killeros_goal_update with status complete and concise evidence.",
361
+ "If useful work remains, call killeros_goal_update with status continue, concrete evidence from this turn, and one concrete nextAction toward the unchanged objective.",
362
+ "If one external impasse persists, call killeros_goal_update with status blocked, the same lowercase blockerKey, and current evidence; attempts one and two record audits and attempt three marks the goal blocked.",
363
+ ]
364
+ : ["This logical turn already has one accepted decision. Do not call killeros_goal_update again; finish the interrupted request, then let KillerOS start the authorized next turn after settlement."]),
365
+ "A normal response without one accepted decision pauses the goal. Do not restate a previous result as progress.",
366
+ "Goal evidence and nextAction are model-reported; KillerOS does not verify progress from prose or tool activity.",
249
367
  "Never use the goal tool to pause, resume, edit, replace, or clear the objective. Those transitions belong to the user.",
250
368
  ].join("\n");
251
369
  }
@@ -258,9 +376,7 @@ function goalContinuationMessage(state: GoalState, ctx: ExtensionContext): strin
258
376
  const sections = [goalInstructions(state, "KillerOS long-running goal turn")];
259
377
  if (ctx.isProjectTrusted()) {
260
378
  const personal = resolvePersonalInstructions(ctx.cwd);
261
- if (personal) {
262
- sections.push(personal);
263
- }
379
+ if (personal) sections.push(personal);
264
380
  }
265
381
  return sections.join("\n\n");
266
382
  }
@@ -282,27 +398,38 @@ export function registerGoalRuntime(
282
398
  runtime: GoalRuntime,
283
399
  ): void {
284
400
  const restoreGoal = (ctx: ExtensionContext): void => {
401
+ runtime.lifecycleGeneration += 1;
402
+ const generation = runtime.lifecycleGeneration;
285
403
  const restored = restoreGoalState(ctx);
286
404
  runtime.state = isGoalModeSupported(ctx) ? restored.state : undefined;
287
405
  syncGoalUpdateTool(pi, runtime);
288
406
  runtime.continuationScheduled = false;
289
407
  runtime.continuationHeld = false;
290
408
  runtime.goalTurnInFlight = false;
409
+ runtime.goalTurn = undefined;
291
410
  runtime.agentEndObserved = false;
292
411
  runtime.automaticCompaction = undefined;
293
412
  runtime.persistenceRetryNeeded = false;
294
413
  runtime.lastStopReason = undefined;
295
414
  runtime.lastError = undefined;
296
415
  runtime.requestRender?.();
297
- if (runtime.state?.status === "active") {
298
- setImmediate(() => scheduleGoalContinuation(pi, runtime, ctx));
416
+ const state = runtime.state;
417
+ if (state?.status !== "active") return;
418
+ if ((state.turnPhase === "in-flight") || (state.turnPhase === undefined && state.turns > 0)) {
419
+ pauseGoalAfterFailure(pi, runtime, ctx, "no turn decision");
420
+ return;
299
421
  }
422
+ setImmediate(() => {
423
+ if (runtime.lifecycleGeneration !== generation || runtime.state !== state) return;
424
+ scheduleGoalContinuation(pi, runtime, ctx, { generation, state });
425
+ });
300
426
  };
301
427
 
302
428
  pi.on("session_start", (_event, ctx) => restoreGoal(ctx));
303
429
  pi.on("session_tree", (_event, ctx) => restoreGoal(ctx));
304
430
 
305
431
  pi.on("session_shutdown", (_event, ctx) => {
432
+ runtime.lifecycleGeneration += 1;
306
433
  if (runtime.state?.status === "active") {
307
434
  const checkpoint = checkpointActiveGoalState(runtime.state, Date.now());
308
435
  try {
@@ -313,14 +440,9 @@ export function registerGoalRuntime(
313
440
  }
314
441
  runtime.state = undefined;
315
442
  syncGoalUpdateTool(pi, runtime);
316
- runtime.continuationScheduled = false;
443
+ clearGoalExecutionFlags(runtime);
317
444
  runtime.continuationHeld = false;
318
- runtime.goalTurnInFlight = false;
319
- runtime.agentEndObserved = false;
320
- runtime.automaticCompaction = undefined;
321
445
  runtime.persistenceRetryNeeded = false;
322
- runtime.lastStopReason = undefined;
323
- runtime.lastError = undefined;
324
446
  });
325
447
 
326
448
  pi.on("before_agent_start", (event, ctx) => {
@@ -328,7 +450,31 @@ export function registerGoalRuntime(
328
450
  if (!runtime.goalTurnInFlight && isGoalModeSupported(ctx) && isSavedSession(ctx) && pauseGoalAtTurnLimit(pi, runtime, ctx)) return;
329
451
  const current = runtime.state;
330
452
  if (!isGoalModeSupported(ctx) || !isSavedSession(ctx) || !current || current.status !== "active") return;
331
- if (runtime.goalTurnInFlight) return { systemPrompt: `${event.systemPrompt}\n\n${goalSystemPrompt(current)}` };
453
+ if (runtime.goalTurnInFlight) {
454
+ if (!goalUpdateToolIsAvailable(pi)) {
455
+ pauseGoalBeforeTurn(pi, runtime, ctx);
456
+ try {
457
+ ctx.abort();
458
+ } catch {
459
+ // The state is already paused; a host abort may be unavailable during startup.
460
+ }
461
+ return;
462
+ }
463
+ return { systemPrompt: `${event.systemPrompt}\n\n${goalSystemPrompt(current)}` };
464
+ }
465
+ if (current.turnPhase === "in-flight") {
466
+ pauseGoalAfterFailure(pi, runtime, ctx, "no turn decision");
467
+ return;
468
+ }
469
+ if (!goalUpdateToolIsAvailable(pi)) {
470
+ pauseGoalBeforeTurn(pi, runtime, ctx);
471
+ try {
472
+ ctx.abort();
473
+ } catch {
474
+ // See the in-flight branch above.
475
+ }
476
+ return;
477
+ }
332
478
  const next = beginGoalTurn(pi, runtime, ctx, current);
333
479
  if (!next) return;
334
480
  return { systemPrompt: `${event.systemPrompt}\n\n${goalSystemPrompt(next)}` };